Add basic headless upload functionality

This commit is contained in:
Sam Becker 2025-02-27 18:00:43 -06:00
parent 5c2954dc00
commit 83188b7190
9 changed files with 196 additions and 65 deletions

View File

@ -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 && {

View File

@ -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}

View File

@ -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>}
/> />
); );
} }

View File

@ -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,
}; };

View File

@ -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!"

View File

@ -72,6 +72,8 @@ export default function MoreMenuItem({
setIsLoading(false); setIsLoading(false);
dismissMenu?.(); dismissMenu?.();
}); });
} else {
dismissMenu?.();
} }
} }
if (href) { if (href) {

View File

@ -43,6 +43,7 @@ export default function PhotoUpload({
setUploadState?.({ setUploadState?.({
isUploading: true, isUploading: true,
uploadError: '', uploadError: '',
hideUploadPanel: true,
}); });
}} }}
onBlobReady={async ({ onBlobReady={async ({

View File

@ -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

View File

@ -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,