Add basic headless upload functionality
This commit is contained in:
parent
5c2954dc00
commit
83188b7190
@ -34,6 +34,7 @@ export default function AdminAppMenu({
|
|||||||
uploadsCount,
|
uploadsCount,
|
||||||
tagsCount,
|
tagsCount,
|
||||||
selectedPhotoIds,
|
selectedPhotoIds,
|
||||||
|
startUpload,
|
||||||
setSelectedPhotoIds,
|
setSelectedPhotoIds,
|
||||||
refreshAdminData,
|
refreshAdminData,
|
||||||
clearAuthStateAndRedirect,
|
clearAuthStateAndRedirect,
|
||||||
@ -47,6 +48,7 @@ export default function AdminAppMenu({
|
|||||||
size={15}
|
size={15}
|
||||||
className="translate-x-[0.5px] translate-y-[0.5px]"
|
className="translate-x-[0.5px] translate-y-[0.5px]"
|
||||||
/>,
|
/>,
|
||||||
|
action: startUpload,
|
||||||
}, {
|
}, {
|
||||||
label: 'Manage Photos',
|
label: 'Manage Photos',
|
||||||
...photosCount !== undefined && {
|
...photosCount !== undefined && {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default function AdminPhotosClient({
|
|||||||
<SiteGrid
|
<SiteGrid
|
||||||
contentMain={
|
contentMain={
|
||||||
<div>
|
<div>
|
||||||
<div className="flex">
|
<div className="flex space-y-4">
|
||||||
<div className="grow min-w-0">
|
<div className="grow min-w-0">
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||||
|
|||||||
@ -1,52 +1,156 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { pathForAdminUploadUrl } from '@/app/paths';
|
||||||
|
import { PATH_ADMIN_UPLOADS } from '@/app/paths';
|
||||||
import Container from '@/components/Container';
|
import Container from '@/components/Container';
|
||||||
|
import ImageInput from '@/components/ImageInput';
|
||||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
import SiteGrid from '@/components/SiteGrid';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
|
import { uploadPhotoFromClient } from '@/platforms/storage';
|
||||||
import { useAppState } from '@/state/AppState';
|
import { useAppState } from '@/state/AppState';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useRef, useTransition } from 'react';
|
||||||
import { IoCloseSharp } from 'react-icons/io5';
|
import { IoCloseSharp } from 'react-icons/io5';
|
||||||
|
|
||||||
export default function AdminUploadPanel() {
|
export default function AdminUploadPanel({
|
||||||
const { uploadState: {
|
shouldResize,
|
||||||
isUploading,
|
onLastUpload,
|
||||||
filesLength,
|
debug,
|
||||||
fileUploadIndex,
|
}: {
|
||||||
fileUploadName,
|
shouldResize: boolean
|
||||||
} } = useAppState();
|
onLastUpload?: () => Promise<void>
|
||||||
|
debug?: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
uploadInputRef,
|
||||||
|
uploadState: {
|
||||||
|
isUploading,
|
||||||
|
filesLength,
|
||||||
|
fileUploadIndex,
|
||||||
|
fileUploadName,
|
||||||
|
uploadError,
|
||||||
|
debugDownload,
|
||||||
|
hideUploadPanel,
|
||||||
|
},
|
||||||
|
setUploadState,
|
||||||
|
resetUploadState,
|
||||||
|
} = useAppState();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const shouldResetUploadStateAfterPending = useRef(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending) {
|
||||||
|
if (shouldResetUploadStateAfterPending.current) {
|
||||||
|
resetUploadState?.();
|
||||||
|
shouldResetUploadStateAfterPending.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isPending, resetUploadState]);
|
||||||
|
const isFinalizing = isPending &&
|
||||||
|
shouldResetUploadStateAfterPending.current;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteGrid contentMain={
|
<SiteGrid
|
||||||
<Container
|
className={clsx((!isUploading || hideUploadPanel) && 'hidden')}
|
||||||
color="gray"
|
contentMain={
|
||||||
padding="tight"
|
<Container
|
||||||
className="p-2! pl-4! text-main!"
|
color="gray"
|
||||||
>
|
padding="tight"
|
||||||
<div className="flex w-full items-center gap-2">
|
className="p-2! pl-4! text-main!"
|
||||||
<div className="grow">
|
>
|
||||||
{isUploading
|
<div className="flex w-full items-center gap-2">
|
||||||
? <div className={clsx('flex items-center gap-4')}>
|
<div className="grow">
|
||||||
<span className="inline-block truncate">
|
<ImageInput
|
||||||
{/* eslint-disable-next-line max-len */}
|
ref={uploadInputRef}
|
||||||
Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName}
|
shouldResize={shouldResize}
|
||||||
</span>
|
onStart={() => {
|
||||||
<Spinner
|
setUploadState?.({
|
||||||
className="text-dim translate-y-[1px]"
|
isUploading: true,
|
||||||
color="text"
|
uploadError: '',
|
||||||
size={14}
|
});
|
||||||
/>
|
}}
|
||||||
</div>
|
onBlobReady={async ({
|
||||||
: 'Upload Photos'}
|
blob,
|
||||||
|
extension,
|
||||||
|
hasMultipleUploads,
|
||||||
|
isLastBlob,
|
||||||
|
}) => {
|
||||||
|
if (debug) {
|
||||||
|
setUploadState?.({
|
||||||
|
isUploading: false,
|
||||||
|
uploadError: '',
|
||||||
|
debugDownload: {
|
||||||
|
href: URL.createObjectURL(blob),
|
||||||
|
fileName: `debug.${extension}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return uploadPhotoFromClient(
|
||||||
|
blob,
|
||||||
|
extension,
|
||||||
|
)
|
||||||
|
.then(async url => {
|
||||||
|
if (isLastBlob) {
|
||||||
|
await onLastUpload?.();
|
||||||
|
shouldResetUploadStateAfterPending.current = true;
|
||||||
|
startTransition(() => hasMultipleUploads
|
||||||
|
? router.push(PATH_ADMIN_UPLOADS)
|
||||||
|
: router.push(pathForAdminUploadUrl(url)));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
setUploadState?.({
|
||||||
|
isUploading: false,
|
||||||
|
uploadError: `Upload Error: ${error.message}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showUploadStatus={false}
|
||||||
|
showUploadButton={false}
|
||||||
|
/>
|
||||||
|
{isUploading
|
||||||
|
? <div className={clsx('flex items-center gap-4')}>
|
||||||
|
{isFinalizing
|
||||||
|
? <span>
|
||||||
|
Finishing
|
||||||
|
</span>
|
||||||
|
: <span className="inline-block truncate">
|
||||||
|
{/* eslint-disable-next-line max-len */}
|
||||||
|
Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName}
|
||||||
|
</span>}
|
||||||
|
<Spinner
|
||||||
|
className="text-dim translate-y-[1px]"
|
||||||
|
color="text"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
: 'Initializing...'}
|
||||||
|
</div>
|
||||||
|
<LoaderButton
|
||||||
|
icon={<IoCloseSharp
|
||||||
|
size={18}
|
||||||
|
className="translate-y-[0.5px]"
|
||||||
|
/>}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LoaderButton
|
{debug && debugDownload &&
|
||||||
icon={<IoCloseSharp
|
<a
|
||||||
size={18}
|
className="block"
|
||||||
className="translate-y-[0.5px]"
|
href={debugDownload.href}
|
||||||
/>}
|
download={debugDownload.fileName}
|
||||||
/>
|
>
|
||||||
</div>
|
Download
|
||||||
</Container>}
|
</a>}
|
||||||
|
{uploadError &&
|
||||||
|
<div className="text-error">
|
||||||
|
{uploadError}
|
||||||
|
</div>}
|
||||||
|
</Container>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface UploadState {
|
|||||||
filesLength: number
|
filesLength: number
|
||||||
fileUploadIndex: number
|
fileUploadIndex: number
|
||||||
fileUploadName: string
|
fileUploadName: string
|
||||||
|
hideUploadPanel: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_UPLOAD_STATE: UploadState = {
|
export const INITIAL_UPLOAD_STATE: UploadState = {
|
||||||
@ -14,4 +15,5 @@ export const INITIAL_UPLOAD_STATE: UploadState = {
|
|||||||
fileUploadName: '',
|
fileUploadName: '',
|
||||||
filesLength: 0,
|
filesLength: 0,
|
||||||
fileUploadIndex: 0,
|
fileUploadIndex: 0,
|
||||||
|
hideUploadPanel: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { blobToImage } from '@/utility/blob';
|
import { blobToImage } from '@/utility/blob';
|
||||||
import { useRef } from 'react';
|
import { useRef, RefObject } from 'react';
|
||||||
import { CopyExif } from '@/lib/CopyExif';
|
import { CopyExif } from '@/lib/CopyExif';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
@ -14,14 +14,17 @@ import { useAppState } from '@/state/AppState';
|
|||||||
const INPUT_ID = 'file';
|
const INPUT_ID = 'file';
|
||||||
|
|
||||||
export default function ImageInput({
|
export default function ImageInput({
|
||||||
|
ref,
|
||||||
onStart,
|
onStart,
|
||||||
onBlobReady,
|
onBlobReady,
|
||||||
shouldResize,
|
shouldResize,
|
||||||
maxSize = MAX_IMAGE_SIZE,
|
maxSize = MAX_IMAGE_SIZE,
|
||||||
quality = 0.8,
|
quality = 0.8,
|
||||||
|
showUploadButton = true,
|
||||||
showUploadStatus = true,
|
showUploadStatus = true,
|
||||||
debug,
|
debug,
|
||||||
}: {
|
}: {
|
||||||
|
ref?: RefObject<HTMLInputElement | null>
|
||||||
onStart?: () => void
|
onStart?: () => void
|
||||||
onBlobReady?: (args: {
|
onBlobReady?: (args: {
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
@ -32,6 +35,7 @@ export default function ImageInput({
|
|||||||
shouldResize?: boolean
|
shouldResize?: boolean
|
||||||
maxSize?: number
|
maxSize?: number
|
||||||
quality?: number
|
quality?: number
|
||||||
|
showUploadButton?: boolean
|
||||||
showUploadStatus?: boolean
|
showUploadStatus?: boolean
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
}) {
|
}) {
|
||||||
@ -50,7 +54,7 @@ export default function ImageInput({
|
|||||||
} = useAppState();
|
} = useAppState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 min-w-0">
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
<label
|
<label
|
||||||
htmlFor={INPUT_ID}
|
htmlFor={INPUT_ID}
|
||||||
@ -59,29 +63,30 @@ export default function ImageInput({
|
|||||||
isUploading && 'pointer-events-none cursor-not-allowed',
|
isUploading && 'pointer-events-none cursor-not-allowed',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ProgressButton
|
{showUploadButton &&
|
||||||
type="button"
|
<ProgressButton
|
||||||
isLoading={isUploading}
|
type="button"
|
||||||
progress={filesLength > 1
|
isLoading={isUploading}
|
||||||
? (fileUploadIndex + 1) / filesLength * 0.95
|
progress={filesLength > 1
|
||||||
: undefined}
|
? (fileUploadIndex + 1) / filesLength * 0.95
|
||||||
icon={<FiUploadCloud
|
: undefined}
|
||||||
size={18}
|
icon={<FiUploadCloud
|
||||||
className="translate-x-[-0.5px] translate-y-[0.5px]"
|
size={18}
|
||||||
/>}
|
className="translate-x-[-0.5px] translate-y-[0.5px]"
|
||||||
aria-disabled={isUploading}
|
/>}
|
||||||
onClick={() => inputRef.current?.click()}
|
aria-disabled={isUploading}
|
||||||
hideTextOnMobile={false}
|
onClick={() => inputRef.current?.click()}
|
||||||
primary
|
hideTextOnMobile={false}
|
||||||
>
|
primary
|
||||||
{isUploading
|
>
|
||||||
? filesLength > 1
|
{isUploading
|
||||||
? `Uploading ${fileUploadIndex + 1} of ${filesLength}`
|
? filesLength > 1
|
||||||
: 'Uploading'
|
? `Uploading ${fileUploadIndex + 1} of ${filesLength}`
|
||||||
: 'Upload Photos'}
|
: 'Uploading'
|
||||||
</ProgressButton>
|
: 'Upload Photos'}
|
||||||
|
</ProgressButton>}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={ref ?? inputRef}
|
||||||
id={INPUT_ID}
|
id={INPUT_ID}
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden!"
|
className="hidden!"
|
||||||
|
|||||||
@ -72,6 +72,8 @@ export default function MoreMenuItem({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
dismissMenu?.();
|
dismissMenu?.();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
dismissMenu?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (href) {
|
if (href) {
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export default function PhotoUpload({
|
|||||||
setUploadState?.({
|
setUploadState?.({
|
||||||
isUploading: true,
|
isUploading: true,
|
||||||
uploadError: '',
|
uploadError: '',
|
||||||
|
hideUploadPanel: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onBlobReady={async ({
|
onBlobReady={async ({
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { Dispatch, SetStateAction, createContext, useContext } from 'react';
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
RefObject,
|
||||||
|
} from 'react';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import { ShareModalProps } from '@/share';
|
import { ShareModalProps } from '@/share';
|
||||||
import { InsightIndicatorStatus } from '@/admin/insights';
|
import { InsightIndicatorStatus } from '@/admin/insights';
|
||||||
@ -16,7 +22,9 @@ export interface AppStateContext {
|
|||||||
clearNextPhotoAnimation?: () => void
|
clearNextPhotoAnimation?: () => void
|
||||||
shouldRespondToKeyboardCommands?: boolean
|
shouldRespondToKeyboardCommands?: boolean
|
||||||
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
|
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
|
||||||
// UPLOADS
|
// UPLOAD
|
||||||
|
startUpload?: () => void
|
||||||
|
uploadInputRef?: RefObject<HTMLInputElement | null>
|
||||||
uploadState: UploadState
|
uploadState: UploadState
|
||||||
setUploadState?: (uploadState: Partial<UploadState>) => void
|
setUploadState?: (uploadState: Partial<UploadState>) => void
|
||||||
resetUploadState?: () => void
|
resetUploadState?: () => void
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, ReactNode, useCallback } from 'react';
|
import { useState, useEffect, ReactNode, useCallback, useRef } from 'react';
|
||||||
import { AppStateContext } from './AppState';
|
import { AppStateContext } from './AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import usePathnames from '@/utility/usePathnames';
|
import usePathnames from '@/utility/usePathnames';
|
||||||
@ -43,6 +43,8 @@ export default function AppStateProvider({
|
|||||||
useState<AnimationConfig>();
|
useState<AnimationConfig>();
|
||||||
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
|
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
|
||||||
useState(true);
|
useState(true);
|
||||||
|
// UPLOAD
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploadState, _setUploadState] =
|
const [uploadState, _setUploadState] =
|
||||||
useState(INITIAL_UPLOAD_STATE);
|
useState(INITIAL_UPLOAD_STATE);
|
||||||
// MODAL
|
// MODAL
|
||||||
@ -88,6 +90,9 @@ export default function AppStateProvider({
|
|||||||
const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] =
|
const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
|
const startUpload = useCallback(() => {
|
||||||
|
uploadInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
const setUploadState = useCallback((uploadState: Partial<UploadState>) => {
|
const setUploadState = useCallback((uploadState: Partial<UploadState>) => {
|
||||||
_setUploadState(prev => ({ ...prev, ...uploadState }));
|
_setUploadState(prev => ({ ...prev, ...uploadState }));
|
||||||
}, []);
|
}, []);
|
||||||
@ -165,7 +170,9 @@ export default function AppStateProvider({
|
|||||||
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
|
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
|
||||||
shouldRespondToKeyboardCommands,
|
shouldRespondToKeyboardCommands,
|
||||||
setShouldRespondToKeyboardCommands,
|
setShouldRespondToKeyboardCommands,
|
||||||
// UPLOADS
|
// UPLOAD
|
||||||
|
uploadInputRef,
|
||||||
|
startUpload,
|
||||||
uploadState,
|
uploadState,
|
||||||
setUploadState,
|
setUploadState,
|
||||||
resetUploadState,
|
resetUploadState,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user