Hoist upload state to app state

This commit is contained in:
Sam Becker 2025-02-27 09:22:24 -06:00
parent de7ef02428
commit 85e83db991
10 changed files with 110 additions and 66 deletions

View File

@ -139,7 +139,7 @@ export default function AdminBatchEditPanelClient({
</LoaderButton>
</>}
<LoaderButton
icon={<IoCloseSharp size={20} className="translate-y-[0.5px]" />}
icon={<IoCloseSharp size={19} />}
onClick={() => setSelectedPhotoIds?.(undefined)}
/>
</>;
@ -155,7 +155,7 @@ export default function AdminBatchEditPanelClient({
<Note
color="gray"
className={clsx(
'min-h-[3.5rem]',
'min-h-[3.5rem] pr-2',
'backdrop-blur-lg border-transparent!',
'text-gray-900! dark:text-gray-100!',
'bg-gray-100/90! dark:bg-gray-900/70!',

View File

@ -4,22 +4,15 @@ import PhotoUpload from '@/photo/PhotoUpload';
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import { useAppState } from '@/state/AppState';
import Link from 'next/link';
import { useState } from 'react';
import { FaArrowRight } from 'react-icons/fa';
export default function AdminCTA() {
const { isUserSignedIn } = useAppState();
const [isUploading, setIsUploading] = useState(false);
return (
<div className="flex justify-center pt-4">
{isUserSignedIn
? <PhotoUpload
showUploadStatus={false}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
? <PhotoUpload showUploadStatus={false} />
: <Link
href={PATH_ADMIN_PHOTOS}
className="button primary"

View File

@ -13,10 +13,10 @@ import PathLoaderButton from '@/components/primitives/PathLoaderButton';
import { PATH_ADMIN_OUTDATED } from '@/app/paths';
import { Photo } from '@/photo';
import { StorageListResponse } from '@/platforms/storage';
import { useState } from 'react';
import { LiaBroomSolid } from 'react-icons/lia';
import AdminUploadsTable from './AdminUploadsTable';
import { Timezone } from '@/utility/timezone';
import { useAppState } from '@/state/AppState';
export default function AdminPhotosClient({
photos,
@ -37,7 +37,7 @@ export default function AdminPhotosClient({
infiniteScrollMultiple: number
timezone: Timezone
}) {
const [isUploading, setIsUploading] = useState(false);
const { uploadState: { isUploading } } = useAppState();
return (
<SiteGrid
@ -47,8 +47,6 @@ export default function AdminPhotosClient({
<div className="grow min-w-0">
<PhotoUpload
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
isUploading={isUploading}
setIsUploading={setIsUploading}
onLastUpload={onLastPhotoUpload}
/>
</div>

View File

@ -1,4 +1,5 @@
import Container from '@/components/Container';
import LoaderButton from '@/components/primitives/LoaderButton';
import SiteGrid from '@/components/SiteGrid';
import Spinner from '@/components/Spinner';
import clsx from 'clsx';
@ -8,8 +9,9 @@ export default function AdminUploadPanel() {
return (
<SiteGrid contentMain={
<Container
color="gray"
padding="tight"
className="px-4 py-4"
className="p-2! pl-4! text-main!"
>
<div className="flex w-full">
<div className={clsx(
@ -23,9 +25,11 @@ export default function AdminUploadPanel() {
/>
1 of 4: Uploading DSC-4353.jpg
</div>
<IoCloseSharp
size={19}
className="translate-y-[0.5px]"
<LoaderButton
icon={<IoCloseSharp
size={18}
className="translate-y-[0.5px]"
/>}
/>
</div>
</Container>}

17
src/admin/upload/index.ts Normal file
View File

@ -0,0 +1,17 @@
export interface UploadState {
isUploading: boolean
uploadError: string
debugDownload?: { href: string, fileName: string }
image?: HTMLImageElement
filesLength: number
fileUploadIndex: number
fileUploadName: string
}
export const INITIAL_UPLOAD_STATE: UploadState = {
isUploading: false,
uploadError: '',
fileUploadName: '',
filesLength: 0,
fileUploadIndex: 0,
};

View File

@ -1,7 +1,7 @@
'use client';
import { blobToImage } from '@/utility/blob';
import { useRef, useState } from 'react';
import { useRef } from 'react';
import { CopyExif } from '@/lib/CopyExif';
import exifr from 'exifr';
import { clsx } from 'clsx/lite';
@ -9,6 +9,7 @@ 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 '@/state/AppState';
const INPUT_ID = 'file';
@ -18,7 +19,6 @@ export default function ImageInput({
shouldResize,
maxSize = MAX_IMAGE_SIZE,
quality = 0.8,
loading,
showUploadStatus = true,
debug,
}: {
@ -32,17 +32,22 @@ export default function ImageInput({
shouldResize?: boolean
maxSize?: number
quality?: number
loading?: boolean
showUploadStatus?: boolean
debug?: boolean
}) {
const inputRef = useRef<HTMLInputElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [image, setImage] = useState<HTMLImageElement>();
const [filesLength, setFilesLength] = useState(0);
const [fileUploadIndex, setFileUploadIndex] = useState(0);
const [fileUploadName, setFileUploadName] = useState('');
const {
uploadState: {
isUploading,
image,
filesLength,
fileUploadIndex,
fileUploadName,
},
setUploadState,
} = useAppState();
return (
<div className="space-y-4 min-w-0">
@ -51,12 +56,12 @@ export default function ImageInput({
htmlFor={INPUT_ID}
className={clsx(
'shrink-0 select-none text-main',
loading && 'pointer-events-none cursor-not-allowed',
isUploading && 'pointer-events-none cursor-not-allowed',
)}
>
<ProgressButton
type="button"
isLoading={loading}
isLoading={isUploading}
progress={filesLength > 1
? (fileUploadIndex + 1) / filesLength * 0.95
: undefined}
@ -64,12 +69,12 @@ export default function ImageInput({
size={18}
className="translate-x-[-0.5px] translate-y-[0.5px]"
/>}
aria-disabled={loading}
aria-disabled={isUploading}
onClick={() => inputRef.current?.click()}
hideTextOnMobile={false}
primary
>
{loading
{isUploading
? filesLength > 1
? `Uploading ${fileUploadIndex + 1} of ${filesLength}`
: 'Uploading'
@ -81,17 +86,19 @@ export default function ImageInput({
type="file"
className="hidden!"
accept={ACCEPTED_PHOTO_FILE_TYPES.join(',')}
disabled={loading}
disabled={isUploading}
multiple
onChange={async e => {
onStart?.();
const { files } = e.currentTarget;
if (files && files.length > 0) {
setFilesLength(files.length);
setUploadState?.({ filesLength: files.length });
for (let i = 0; i < files.length; i++) {
const file = files[i];
setFileUploadIndex(i);
setFileUploadName(file.name);
setUploadState?.({
fileUploadIndex: i,
fileUploadName: file.name,
});
const callbackArgs = {
extension: file.name.split('.').pop()?.toLowerCase(),
hasMultipleUploads: files.length > 1,
@ -111,7 +118,7 @@ export default function ImageInput({
// Process images that need resizing
const image = await blobToImage(file);
setImage(image);
setUploadState?.({ image });
ctx.save();

View File

@ -74,17 +74,21 @@ export default function MoreMenuItem({
});
}
}
if (href && href !== pathname) {
if (hrefDownloadName) {
setIsLoading(true);
downloadFileFromBrowser(href, hrefDownloadName)
.finally(() => {
setIsLoading(false);
dismissMenu?.();
});
if (href) {
if (href !== pathname) {
if (hrefDownloadName) {
setIsLoading(true);
downloadFileFromBrowser(href, hrefDownloadName)
.finally(() => {
setIsLoading(false);
dismissMenu?.();
});
} else {
setTransitionDidStart(true);
startTransition(() => router.push(href));
}
} else {
setTransitionDidStart(true);
startTransition(() => router.push(href));
dismissMenu?.();
}
}
}}

View File

@ -1,32 +1,31 @@
'use client';
import { useState } from 'react';
import { uploadPhotoFromClient } from '@/platforms/storage';
import { useRouter } from 'next/navigation';
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
import ImageInput from '../components/ImageInput';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
export default function PhotoUpload({
shouldResize,
onLastUpload,
isUploading,
setIsUploading,
showUploadStatus,
debug,
}: {
shouldResize?: boolean
onLastUpload?: () => Promise<void>
isUploading: boolean
setIsUploading: (isUploading: boolean) => void
showUploadStatus?: boolean
debug?: boolean
}) {
const [uploadError, setUploadError] = useState<string>();
const [debugDownload, setDebugDownload] = useState<{
href: string
fileName: string
}>();
const {
uploadState: {
isUploading,
uploadError,
debugDownload,
},
setUploadState,
} = useAppState();
const router = useRouter();
@ -38,11 +37,12 @@ export default function PhotoUpload({
<div className="flex items-center gap-8">
<form className="flex items-center min-w-0">
<ImageInput
loading={isUploading}
shouldResize={shouldResize}
onStart={() => {
setIsUploading(true);
setUploadError('');
setUploadState?.({
isUploading: true,
uploadError: '',
});
}}
onBlobReady={async ({
blob,
@ -51,12 +51,14 @@ export default function PhotoUpload({
isLastBlob,
}) => {
if (debug) {
setDebugDownload({
href: URL.createObjectURL(blob),
fileName: `debug.${extension}`,
setUploadState?.({
isUploading: false,
uploadError: '',
debugDownload: {
href: URL.createObjectURL(blob),
fileName: `debug.${extension}`,
},
});
setIsUploading(false);
setUploadError('');
} else {
return uploadPhotoFromClient(
blob,
@ -75,8 +77,10 @@ export default function PhotoUpload({
}
})
.catch(error => {
setIsUploading(false);
setUploadError(`Upload Error: ${error.message}`);
setUploadState?.({
isUploading: false,
uploadError: `Upload Error: ${error.message}`,
});
});
}
}}

View File

@ -2,6 +2,7 @@ import { Dispatch, SetStateAction, createContext, useContext } from 'react';
import { AnimationConfig } from '@/components/AnimateItems';
import { ShareModalProps } from '@/share';
import { InsightIndicatorStatus } from '@/admin/insights';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
export interface AppStateContext {
// CORE
@ -15,6 +16,9 @@ export interface AppStateContext {
clearNextPhotoAnimation?: () => void
shouldRespondToKeyboardCommands?: boolean
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
// UPLOADS
uploadState: UploadState
setUploadState?: (uploadState: Partial<UploadState>) => void
// MODAL
isCommandKOpen?: boolean
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
@ -57,6 +61,8 @@ export interface AppStateContext {
setShouldDebugRecipeOverlays?: Dispatch<SetStateAction<boolean>>
}
export const AppStateContext = createContext<AppStateContext>({});
export const AppStateContext = createContext<AppStateContext>({
uploadState: INITIAL_UPLOAD_STATE,
});
export const useAppState = () => useContext(AppStateContext);

View File

@ -23,6 +23,7 @@ import {
} from '@/auth/client';
import { useRouter } from 'next/navigation';
import { PATH_SIGN_IN } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
export default function AppStateProvider({
children,
@ -42,12 +43,14 @@ export default function AppStateProvider({
useState<AnimationConfig>();
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
useState(true);
const [uploadState, _setUploadState] =
useState(INITIAL_UPLOAD_STATE);
// MODAL
const [isCommandKOpen, setIsCommandKOpen] =
useState(false);
const [shareModalProps, setShareModalProps] =
useState<ShareModalProps>();
// ADMIN
// AUTH
const [userEmail, setUserEmail] =
useState<string>();
const [isUserSignedInEager, setIsUserSignedInEager] =
@ -85,6 +88,10 @@ export default function AppStateProvider({
const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] =
useState(false);
const setUploadState = useCallback((uploadState: Partial<UploadState>) => {
_setUploadState(prev => ({ ...prev, ...uploadState }));
}, []);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const { data: auth, error: authError } = useSWR('getAuth', getAuthAction);
@ -136,6 +143,7 @@ export default function AppStateProvider({
const clearAuthStateAndRedirect = useCallback((shouldRedirect = true) => {
setUserEmail(undefined);
setIsUserSignedInEager(false);
clearAuthEmailCookie();
if (shouldRedirect) { router.push(PATH_SIGN_IN); }
}, [router]);
@ -154,6 +162,9 @@ export default function AppStateProvider({
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
shouldRespondToKeyboardCommands,
setShouldRespondToKeyboardCommands,
// UPLOADS
uploadState,
setUploadState,
// MODAL
isCommandKOpen,
setIsCommandKOpen,