From 44bf571dda5066c2d5cce03c01204f26e3b0c21b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 26 Feb 2025 23:37:31 -0600 Subject: [PATCH 1/6] Create upload status panel --- app/layout.tsx | 2 ++ src/admin/AdminAppMenu.tsx | 7 +++++++ src/admin/AdminUploadPanel.tsx | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/admin/AdminUploadPanel.tsx diff --git a/app/layout.tsx b/app/layout.tsx index df6a17b5..0cb9993c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,6 +19,7 @@ import CommandK from '@/app/CommandK'; import SwrConfigClient from '@/state/SwrConfigClient'; import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel'; import ShareModals from '@/share/ShareModals'; +import AdminUploadPanel from '@/admin/AdminUploadPanel'; import '../tailwind.css'; @@ -88,6 +89,7 @@ export default function RootLayout({ 'mb-12', 'space-y-5', )}> + {children} diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index af6714a7..0fe2dea7 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -20,6 +20,7 @@ import { PiSignOutBold } from 'react-icons/pi'; import { signOutAction } from '@/auth/actions'; import { ComponentProps } from 'react'; import { FaRegFolderOpen } from 'react-icons/fa'; +import { FiUploadCloud } from 'react-icons/fi'; export default function AdminAppMenu({ className, @@ -41,6 +42,12 @@ export default function AdminAppMenu({ const isSelecting = selectedPhotoIds !== undefined; const items: ComponentProps['items'] = [{ + label: 'Upload Photos …', + icon: , + }, { label: 'Manage Photos', ...photosCount !== undefined && { annotation: `${photosCount}`, diff --git a/src/admin/AdminUploadPanel.tsx b/src/admin/AdminUploadPanel.tsx new file mode 100644 index 00000000..faaed0d9 --- /dev/null +++ b/src/admin/AdminUploadPanel.tsx @@ -0,0 +1,34 @@ +import Container from '@/components/Container'; +import SiteGrid from '@/components/SiteGrid'; +import Spinner from '@/components/Spinner'; +import clsx from 'clsx'; +import { IoCloseSharp } from 'react-icons/io5'; + +export default function AdminUploadPanel() { + return ( + +
+
+ + 1 of 4: Uploading DSC-4353.jpg +
+ +
+ } + /> + ); +} From 85e83db9918b3c38e590b70143a936d677749268 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 27 Feb 2025 09:22:24 -0600 Subject: [PATCH 2/6] Hoist upload state to app state --- src/admin/AdminBatchEditPanelClient.tsx | 4 +-- src/admin/AdminCTA.tsx | 9 +---- src/admin/AdminPhotosClient.tsx | 6 ++-- src/admin/AdminUploadPanel.tsx | 12 ++++--- src/admin/upload/index.ts | 17 ++++++++++ src/components/ImageInput.tsx | 39 +++++++++++++--------- src/components/more/MoreMenuItem.tsx | 24 ++++++++------ src/photo/PhotoUpload.tsx | 44 ++++++++++++++----------- src/state/AppState.ts | 8 ++++- src/state/AppStateProvider.tsx | 13 +++++++- 10 files changed, 110 insertions(+), 66 deletions(-) create mode 100644 src/admin/upload/index.ts diff --git a/src/admin/AdminBatchEditPanelClient.tsx b/src/admin/AdminBatchEditPanelClient.tsx index 4bc4304b..806c6808 100644 --- a/src/admin/AdminBatchEditPanelClient.tsx +++ b/src/admin/AdminBatchEditPanelClient.tsx @@ -139,7 +139,7 @@ export default function AdminBatchEditPanelClient({ } } + icon={} onClick={() => setSelectedPhotoIds?.(undefined)} /> ; @@ -155,7 +155,7 @@ export default function AdminBatchEditPanelClient({ {isUserSignedIn - ? + ? : diff --git a/src/admin/AdminUploadPanel.tsx b/src/admin/AdminUploadPanel.tsx index faaed0d9..1c8fc73f 100644 --- a/src/admin/AdminUploadPanel.tsx +++ b/src/admin/AdminUploadPanel.tsx @@ -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 (
1 of 4: Uploading DSC-4353.jpg
- } />
} diff --git a/src/admin/upload/index.ts b/src/admin/upload/index.ts new file mode 100644 index 00000000..b908a791 --- /dev/null +++ b/src/admin/upload/index.ts @@ -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, +}; diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 3bac3e98..6ae4304c 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -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(null); const canvasRef = useRef(null); - const [image, setImage] = useState(); - const [filesLength, setFilesLength] = useState(0); - const [fileUploadIndex, setFileUploadIndex] = useState(0); - const [fileUploadName, setFileUploadName] = useState(''); + const { + uploadState: { + isUploading, + image, + filesLength, + fileUploadIndex, + fileUploadName, + }, + setUploadState, + } = useAppState(); return (
@@ -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', )} > 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(); diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index fe54671d..26e82c37 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -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?.(); } } }} diff --git a/src/photo/PhotoUpload.tsx b/src/photo/PhotoUpload.tsx index 1e1b7717..6ebacf17 100644 --- a/src/photo/PhotoUpload.tsx +++ b/src/photo/PhotoUpload.tsx @@ -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 - isUploading: boolean - setIsUploading: (isUploading: boolean) => void showUploadStatus?: boolean debug?: boolean }) { - const [uploadError, setUploadError] = useState(); - 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({
{ - 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}`, + }); }); } }} diff --git a/src/state/AppState.ts b/src/state/AppState.ts index c0091658..07d3c550 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -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> + // UPLOADS + uploadState: UploadState + setUploadState?: (uploadState: Partial) => void // MODAL isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> @@ -57,6 +61,8 @@ export interface AppStateContext { setShouldDebugRecipeOverlays?: Dispatch> } -export const AppStateContext = createContext({}); +export const AppStateContext = createContext({ + uploadState: INITIAL_UPLOAD_STATE, +}); export const useAppState = () => useContext(AppStateContext); diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index 1124cca4..0b6a93cb 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -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(); const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] = useState(true); + const [uploadState, _setUploadState] = + useState(INITIAL_UPLOAD_STATE); // MODAL const [isCommandKOpen, setIsCommandKOpen] = useState(false); const [shareModalProps, setShareModalProps] = useState(); - // ADMIN + // AUTH const [userEmail, setUserEmail] = useState(); const [isUserSignedInEager, setIsUserSignedInEager] = @@ -85,6 +88,10 @@ export default function AppStateProvider({ const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] = useState(false); + const setUploadState = useCallback((uploadState: Partial) => { + _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, From 5c2954dc005c74251847e49a80328263401c7297 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 27 Feb 2025 09:34:12 -0600 Subject: [PATCH 3/6] Add upload status to app-level panel --- app/layout.tsx | 2 +- src/admin/AdminUploadPanel.tsx | 38 -------------------- src/admin/upload/AdminUploadPanel.tsx | 52 +++++++++++++++++++++++++++ src/photo/PhotoUpload.tsx | 2 ++ src/state/AppState.ts | 1 + src/state/AppStateProvider.tsx | 4 +++ 6 files changed, 60 insertions(+), 39 deletions(-) delete mode 100644 src/admin/AdminUploadPanel.tsx create mode 100644 src/admin/upload/AdminUploadPanel.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 0cb9993c..f2d687ec 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,7 +19,7 @@ import CommandK from '@/app/CommandK'; import SwrConfigClient from '@/state/SwrConfigClient'; import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel'; import ShareModals from '@/share/ShareModals'; -import AdminUploadPanel from '@/admin/AdminUploadPanel'; +import AdminUploadPanel from '@/admin/upload/AdminUploadPanel'; import '../tailwind.css'; diff --git a/src/admin/AdminUploadPanel.tsx b/src/admin/AdminUploadPanel.tsx deleted file mode 100644 index 1c8fc73f..00000000 --- a/src/admin/AdminUploadPanel.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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'; -import { IoCloseSharp } from 'react-icons/io5'; - -export default function AdminUploadPanel() { - return ( - -
-
- - 1 of 4: Uploading DSC-4353.jpg -
- } - /> -
- } - /> - ); -} diff --git a/src/admin/upload/AdminUploadPanel.tsx b/src/admin/upload/AdminUploadPanel.tsx new file mode 100644 index 00000000..d4f150d3 --- /dev/null +++ b/src/admin/upload/AdminUploadPanel.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Container from '@/components/Container'; +import LoaderButton from '@/components/primitives/LoaderButton'; +import SiteGrid from '@/components/SiteGrid'; +import Spinner from '@/components/Spinner'; +import { useAppState } from '@/state/AppState'; +import clsx from 'clsx'; +import { IoCloseSharp } from 'react-icons/io5'; + +export default function AdminUploadPanel() { + const { uploadState: { + isUploading, + filesLength, + fileUploadIndex, + fileUploadName, + } } = useAppState(); + + return ( + +
+
+ {isUploading + ?
+ + {/* eslint-disable-next-line max-len */} + Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName} + + +
+ : 'Upload Photos'} +
+ } + /> +
+ } + /> + ); +} diff --git a/src/photo/PhotoUpload.tsx b/src/photo/PhotoUpload.tsx index 6ebacf17..0482ba1e 100644 --- a/src/photo/PhotoUpload.tsx +++ b/src/photo/PhotoUpload.tsx @@ -25,6 +25,7 @@ export default function PhotoUpload({ debugDownload, }, setUploadState, + resetUploadState, } = useAppState(); const router = useRouter(); @@ -67,6 +68,7 @@ export default function PhotoUpload({ .then(async url => { if (isLastBlob) { await onLastUpload?.(); + resetUploadState?.(); if (hasMultipleUploads) { // Redirect to view multiple uploads router.push(PATH_ADMIN_UPLOADS); diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 07d3c550..2c49c8b3 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -19,6 +19,7 @@ export interface AppStateContext { // UPLOADS uploadState: UploadState setUploadState?: (uploadState: Partial) => void + resetUploadState?: () => void // MODAL isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index 0b6a93cb..6e0781b3 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -91,6 +91,9 @@ export default function AppStateProvider({ const setUploadState = useCallback((uploadState: Partial) => { _setUploadState(prev => ({ ...prev, ...uploadState })); }, []); + const resetUploadState = useCallback(() => { + _setUploadState(INITIAL_UPLOAD_STATE); + }, []); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); @@ -165,6 +168,7 @@ export default function AppStateProvider({ // UPLOADS uploadState, setUploadState, + resetUploadState, // MODAL isCommandKOpen, setIsCommandKOpen, From 83188b7190fb22bbbdfbe37e57775fab4438f918 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 27 Feb 2025 18:00:43 -0600 Subject: [PATCH 4/6] Add basic headless upload functionality --- src/admin/AdminAppMenu.tsx | 2 + src/admin/AdminPhotosClient.tsx | 2 +- src/admin/upload/AdminUploadPanel.tsx | 176 ++++++++++++++++++++------ src/admin/upload/index.ts | 2 + src/components/ImageInput.tsx | 53 ++++---- src/components/more/MoreMenuItem.tsx | 2 + src/photo/PhotoUpload.tsx | 1 + src/state/AppState.ts | 12 +- src/state/AppStateProvider.tsx | 11 +- 9 files changed, 196 insertions(+), 65 deletions(-) diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 0fe2dea7..4c2e5b05 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -34,6 +34,7 @@ export default function AdminAppMenu({ uploadsCount, tagsCount, selectedPhotoIds, + startUpload, setSelectedPhotoIds, refreshAdminData, clearAuthStateAndRedirect, @@ -47,6 +48,7 @@ export default function AdminAppMenu({ size={15} className="translate-x-[0.5px] translate-y-[0.5px]" />, + action: startUpload, }, { label: 'Manage Photos', ...photosCount !== undefined && { diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index cd04724a..d76317de 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -43,7 +43,7 @@ export default function AdminPhotosClient({ -
+
Promise + 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 ( - -
-
- {isUploading - ?
- - {/* eslint-disable-next-line max-len */} - Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName} - - -
- : 'Upload Photos'} + +
+
+ { + setUploadState?.({ + isUploading: true, + uploadError: '', + }); + }} + onBlobReady={async ({ + 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 + ?
+ {isFinalizing + ? + Finishing + + : + {/* eslint-disable-next-line max-len */} + Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName} + } + +
+ : 'Initializing...'} +
+ } + />
- } - /> -
- } + {debug && debugDownload && + + Download + } + {uploadError && +
+ {uploadError} +
} + } /> ); } diff --git a/src/admin/upload/index.ts b/src/admin/upload/index.ts index b908a791..d8003a57 100644 --- a/src/admin/upload/index.ts +++ b/src/admin/upload/index.ts @@ -6,6 +6,7 @@ export interface UploadState { filesLength: number fileUploadIndex: number fileUploadName: string + hideUploadPanel: boolean } export const INITIAL_UPLOAD_STATE: UploadState = { @@ -14,4 +15,5 @@ export const INITIAL_UPLOAD_STATE: UploadState = { fileUploadName: '', filesLength: 0, fileUploadIndex: 0, + hideUploadPanel: false, }; diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 6ae4304c..c93a4097 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -1,7 +1,7 @@ 'use client'; import { blobToImage } from '@/utility/blob'; -import { useRef } from 'react'; +import { useRef, RefObject } from 'react'; import { CopyExif } from '@/lib/CopyExif'; import exifr from 'exifr'; import { clsx } from 'clsx/lite'; @@ -14,14 +14,17 @@ import { useAppState } from '@/state/AppState'; const INPUT_ID = 'file'; export default function ImageInput({ + ref, onStart, onBlobReady, shouldResize, maxSize = MAX_IMAGE_SIZE, quality = 0.8, + showUploadButton = true, showUploadStatus = true, debug, }: { + ref?: RefObject onStart?: () => void onBlobReady?: (args: { blob: Blob, @@ -32,6 +35,7 @@ export default function ImageInput({ shouldResize?: boolean maxSize?: number quality?: number + showUploadButton?: boolean showUploadStatus?: boolean debug?: boolean }) { @@ -50,7 +54,7 @@ export default function ImageInput({ } = useAppState(); return ( -
+
diff --git a/src/admin/AdminAddAllUploads.tsx b/src/admin/AdminAddAllUploads.tsx index 2184b93a..024b285e 100644 --- a/src/admin/AdminAddAllUploads.tsx +++ b/src/admin/AdminAddAllUploads.tsx @@ -19,6 +19,8 @@ import { BiCheckCircle, BiImageAdd } from 'react-icons/bi'; import ProgressButton from '@/components/primitives/ProgressButton'; import { UrlAddStatus } from './AdminUploadsClient'; import PhotoTagFieldset from './PhotoTagFieldset'; +import DeleteUploadButton from './DeleteUploadButton'; +import ResponsiveText from '@/components/primitives/ResponsiveText'; const UPLOAD_BATCH_SIZE = 4; @@ -140,7 +142,10 @@ export default function AdminAddAllUploads({ disabled={Boolean(tagErrorMessage) || isAddingComplete} icon={isAddingComplete ? - : + : } onClick={async () => { // eslint-disable-next-line max-len @@ -178,6 +183,17 @@ export default function AdminAddAllUploads({ > {buttonText} + router.push(PATH_ADMIN_PHOTOS)} + className="w-full flex justify-center" + shouldRedirectToAdminPhotos + hideTextOnMobile={false} + > + + Delete All Uploads + +
diff --git a/src/admin/AdminCTA.tsx b/src/admin/AdminCTA.tsx index d03776a2..589bc20d 100644 --- a/src/admin/AdminCTA.tsx +++ b/src/admin/AdminCTA.tsx @@ -1,18 +1,29 @@ 'use client'; -import PhotoUpload from '@/photo/PhotoUpload'; +import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; import { PATH_ADMIN_PHOTOS } from '@/app/paths'; import { useAppState } from '@/state/AppState'; import Link from 'next/link'; import { FaArrowRight } from 'react-icons/fa'; -export default function AdminCTA() { +export default function AdminCTA({ + shouldResize, + onLastUpload, +}: { + shouldResize: boolean + onLastUpload: () => Promise +}) { const { isUserSignedIn } = useAppState(); return (
{isUserSignedIn - ? + ? : Promise blobPhotoUrls: StorageListResponse + shouldResize: boolean + onLastUpload: () => Promise infiniteScrollInitial: number infiniteScrollMultiple: number timezone: Timezone @@ -43,11 +44,12 @@ export default function AdminPhotosClient({ -
+
-
{photosCountOutdated > 0 && setUrlAddStatuses?.(urlAddStatuses.filter( ({ url: urlToRemove }) => urlToRemove !== url, diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx index 4d8381c3..df891d71 100644 --- a/src/admin/DeleteButton.tsx +++ b/src/admin/DeleteButton.tsx @@ -14,7 +14,7 @@ export default function DeleteButton({ icon={} spinnerColor="text" className={clsx( - 'text-red-500! dark:text-red-600!', + 'text-red-500! dark:text-red-500!', 'active:bg-red-100/50! dark:active:bg-red-950/50!', 'disabled:bg-red-100/50! dark:disabled:bg-red-950/50!', 'border-red-200! hover:border-red-300!', diff --git a/src/admin/DeleteBlobButton.tsx b/src/admin/DeleteUploadButton.tsx similarity index 58% rename from src/admin/DeleteBlobButton.tsx rename to src/admin/DeleteUploadButton.tsx index 12914ce8..2faed481 100644 --- a/src/admin/DeleteBlobButton.tsx +++ b/src/admin/DeleteUploadButton.tsx @@ -1,19 +1,25 @@ 'use client'; -import { deleteUploadAction } from '@/photo/actions'; +import { deleteUploadsAction } from '@/photo/actions'; import DeleteButton from './DeleteButton'; import { useRouter } from 'next/navigation'; import { PATH_ADMIN_PHOTOS } from '@/app/paths'; -import { useState } from 'react'; +import { ReactNode, useState } from 'react'; export default function DeleteUploadButton({ - url, + urls, shouldRedirectToAdminPhotos, onDelete, + hideTextOnMobile, + children, + className, }: { - url: string + urls: string[] shouldRedirectToAdminPhotos?: boolean onDelete?: () => void + hideTextOnMobile?: boolean + children?: ReactNode + className?: string }) { const router = useRouter(); @@ -21,10 +27,13 @@ export default function DeleteUploadButton({ return ( { setIsDeleting(true); - deleteUploadAction(url) + deleteUploadsAction(urls) .then(() => { onDelete?.(); if (shouldRedirectToAdminPhotos) { @@ -36,6 +45,9 @@ export default function DeleteUploadButton({ .catch(() => setIsDeleting(false)); }} isLoading={isDeleting} - /> + hideTextOnMobile={hideTextOnMobile} + > + {children} + ); } diff --git a/src/admin/upload/AdminUploadPanel.tsx b/src/admin/upload/AdminUploadPanel.tsx index 154fe7ee..66ca9655 100644 --- a/src/admin/upload/AdminUploadPanel.tsx +++ b/src/admin/upload/AdminUploadPanel.tsx @@ -1,61 +1,38 @@ 'use client'; -import { pathForAdminUploadUrl } from '@/app/paths'; -import { PATH_ADMIN_UPLOADS } from '@/app/paths'; +import { isPathAdminPhotos } from '@/app/paths'; import Container from '@/components/Container'; -import ImageInput from '@/components/ImageInput'; import LoaderButton from '@/components/primitives/LoaderButton'; import SiteGrid from '@/components/SiteGrid'; -import Spinner from '@/components/Spinner'; -import { uploadPhotoFromClient } from '@/platforms/storage'; +import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; import { useAppState } from '@/state/AppState'; import clsx from 'clsx'; -import { useRouter } from 'next/navigation'; -import { useEffect, useRef, useTransition } from 'react'; +import { usePathname } from 'next/navigation'; import { IoCloseSharp } from 'react-icons/io5'; export default function AdminUploadPanel({ shouldResize, onLastUpload, - debug, }: { shouldResize: boolean - onLastUpload?: () => Promise - debug?: boolean + onLastUpload: () => Promise }) { + const pathname = usePathname(); + 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 (
-
- { - setUploadState?.({ - isUploading: true, - uploadError: '', - }); - }} - onBlobReady={async ({ - 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 - ?
- {isFinalizing - ? - Finishing - - : - {/* eslint-disable-next-line max-len */} - Uploading {fileUploadIndex + 1} of {filesLength}: {fileUploadName} - } - -
- : 'Initializing...'} -
+ } + onClick={resetUploadState} />
- {debug && debugDownload && - - Download - } - {uploadError && -
- {uploadError} -
} } /> ); diff --git a/src/admin/upload/index.ts b/src/admin/upload/index.ts index d8003a57..ec187579 100644 --- a/src/admin/upload/index.ts +++ b/src/admin/upload/index.ts @@ -3,17 +3,15 @@ export interface UploadState { uploadError: string debugDownload?: { href: string, fileName: string } image?: HTMLImageElement - filesLength: number - fileUploadIndex: number fileUploadName: string - hideUploadPanel: boolean + fileUploadIndex: number + filesLength: number } export const INITIAL_UPLOAD_STATE: UploadState = { isUploading: false, uploadError: '', fileUploadName: '', - filesLength: 0, fileUploadIndex: 0, - hideUploadPanel: false, + filesLength: 0, }; diff --git a/src/app/paths.ts b/src/app/paths.ts index e03a7ac1..00ff9b6e 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -226,6 +226,9 @@ export const isPathAdmin = (pathname?: string) => export const isPathTopLevelAdmin = (pathname?: string) => PATHS_ADMIN.some(path => path === pathname); +export const isPathAdminPhotos = (pathname?: string) => + checkPathPrefix(pathname, PATH_ADMIN_PHOTOS); + export const isPathAdminInsights = (pathname?: string) => checkPathPrefix(pathname, PATH_ADMIN_INSIGHTS); diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index c93a4097..a1e716e4 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -11,20 +11,20 @@ import { MAX_IMAGE_SIZE } from '@/platforms/next-image'; import ProgressButton from './primitives/ProgressButton'; import { useAppState } from '@/state/AppState'; -const INPUT_ID = 'file'; - export default function ImageInput({ - ref, + ref: inputRefExternal, + id = 'file', onStart, onBlobReady, shouldResize, maxSize = MAX_IMAGE_SIZE, quality = 0.8, - showUploadButton = true, - showUploadStatus = true, + showButton, + disabled: disabledProp, debug, }: { ref?: RefObject + id?: string onStart?: () => void onBlobReady?: (args: { blob: Blob, @@ -35,38 +35,42 @@ export default function ImageInput({ shouldResize?: boolean maxSize?: number quality?: number - showUploadButton?: boolean - showUploadStatus?: boolean + showButton?: boolean + disabled?: boolean debug?: boolean }) { - const inputRef = useRef(null); + const inputRefInternal = useRef(null); const canvasRef = useRef(null); + const inputRef = inputRefExternal ?? inputRefInternal; + const { uploadState: { isUploading, image, filesLength, fileUploadIndex, - fileUploadName, }, setUploadState, + resetUploadState, } = useAppState(); + const disabled = disabledProp || isUploading; + return (
- {showUploadStatus && filesLength > 0 && -
- {fileUploadName} -
}
Promise - showUploadStatus?: boolean - debug?: boolean -}) { - const { - uploadState: { - isUploading, - uploadError, - debugDownload, - }, - setUploadState, - resetUploadState, - } = useAppState(); - - const router = useRouter(); - - return ( -
-
- - { - setUploadState?.({ - isUploading: true, - uploadError: '', - hideUploadPanel: true, - }); - }} - onBlobReady={async ({ - 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?.(); - resetUploadState?.(); - if (hasMultipleUploads) { - // Redirect to view multiple uploads - router.push(PATH_ADMIN_UPLOADS); - } else { - // Redirect to photo detail page - router.push(pathForAdminUploadUrl(url)); - } - } - }) - .catch(error => { - setUploadState?.({ - isUploading: false, - uploadError: `Upload Error: ${error.message}`, - }); - }); - } - }} - showUploadStatus={showUploadStatus} - debug={debug} - /> - -
- {debug && debugDownload && - - Download - } - {uploadError && -
- {uploadError} -
} -
- ); -}; diff --git a/src/photo/PhotoUploadWithStatus.tsx b/src/photo/PhotoUploadWithStatus.tsx new file mode 100644 index 00000000..c48df144 --- /dev/null +++ b/src/photo/PhotoUploadWithStatus.tsx @@ -0,0 +1,163 @@ +'use client'; + +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'; +import { RefObject, useTransition } from 'react'; +import { useRef } from 'react'; +import { useEffect } from 'react'; +import Spinner from '@/components/Spinner'; + +export default function PhotoUploadWithStatus({ + inputRef, + inputId, + shouldResize, + onLastUpload, + showStatusText = true, + showButton = true, + className, + debug, +}: { + inputRef?: RefObject + inputId: string + shouldResize: boolean + onLastUpload?: () => Promise + showStatusText?: boolean + showButton?: boolean + className?: string + debug?: boolean +}) { + const { + uploadState: { + isUploading, + uploadError, + fileUploadName, + fileUploadIndex, + filesLength, + debugDownload, + }, + setUploadState, + resetUploadState, + } = useAppState(); + + const router = useRouter(); + + const shouldResetUploadStateAfterPending = useRef(false); + const [isPending, startTransition] = useTransition(); + useEffect(() => { + if (!isPending && shouldResetUploadStateAfterPending.current) { + resetUploadState?.(); + shouldResetUploadStateAfterPending.current = false; + } + return () => { + if (shouldResetUploadStateAfterPending.current) { + resetUploadState?.(); + } + }; + }, [isPending, resetUploadState]); + const isFinishing = isPending && shouldResetUploadStateAfterPending.current; + + return ( +
+
+ { + setUploadState?.({ + isUploading: true, + uploadError: '', + }); + }} + onBlobReady={async ({ + 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}`, + }); + }); + } + }} + showButton={showButton} + debug={debug} + /> +
+ {showStatusText &&
+ {isUploading && !showButton && + } + + {isUploading + ? isFinishing + ? <> + Finishing + + : <> + {!showButton && + `Uploading ${fileUploadIndex + 1} of ${filesLength}: `} + {fileUploadName} + + : !showButton && <>Initializing} + +
} + {debug && debugDownload && + + Download + } + {uploadError && +
+ {uploadError} +
} +
+ ); +}; diff --git a/src/photo/PhotosEmptyState.tsx b/src/photo/PhotosEmptyState.tsx index ea7cfa78..c8377e23 100644 --- a/src/photo/PhotosEmptyState.tsx +++ b/src/photo/PhotosEmptyState.tsx @@ -1,12 +1,13 @@ import AdminCTA from '@/admin/AdminCTA'; import Container from '@/components/Container'; import SiteGrid from '@/components/SiteGrid'; -import { IS_SITE_READY } from '@/app/config'; +import { IS_SITE_READY, PRESERVE_ORIGINAL_UPLOADS } from '@/app/config'; import { PATH_ADMIN_CONFIGURATION } from '@/app/paths'; import AdminAppConfiguration from '@/admin/AdminAppConfiguration'; import { clsx } from 'clsx/lite'; import Link from 'next/link'; import { HiOutlinePhotograph } from 'react-icons/hi'; +import { revalidatePath } from 'next/cache'; export default function PhotosEmptyState() { return ( @@ -33,7 +34,14 @@ export default function PhotosEmptyState() {
Add your first photo:
- + { + 'use server'; + // Update upload count in admin nav + revalidatePath('/admin', 'layout'); + }} + />
Change the name of this blog and other configuration diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 9b741559..2f038308 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -290,9 +290,9 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) => } }); -export const deleteUploadAction = async (url: string) => +export const deleteUploadsAction = async (urls: string[]) => runAuthenticatedAdminServerAction(async () => { - await deleteFile(url); + await Promise.all(urls.map(url => deleteFile(url))); revalidateAdminPaths(); }); diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index a5bcf1d7..07c73742 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -30,10 +30,10 @@ export default function AppStateProvider({ }: { children: ReactNode }) { - const { previousPathname } = usePathnames(); - const router = useRouter(); + const { previousPathname } = usePathnames(); + // CORE const [hasLoaded, setHasLoaded] = useState(false); @@ -91,7 +91,10 @@ export default function AppStateProvider({ useState(false); const startUpload = useCallback(() => { - uploadInputRef.current?.click(); + if (uploadInputRef.current) { + uploadInputRef.current.value = ''; + uploadInputRef.current.click(); + } }, []); const setUploadState = useCallback((uploadState: Partial) => { _setUploadState(prev => ({ ...prev, ...uploadState })); From f1b90b55e93e8c7eaaf44eb792b546724c7d02c5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 27 Feb 2025 23:27:30 -0600 Subject: [PATCH 6/6] Finesse upload panel visibility --- src/admin/upload/AdminUploadPanel.tsx | 10 ++-------- src/admin/upload/index.ts | 2 ++ src/photo/PhotoUploadWithStatus.tsx | 8 ++++++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/admin/upload/AdminUploadPanel.tsx b/src/admin/upload/AdminUploadPanel.tsx index 66ca9655..7be0a887 100644 --- a/src/admin/upload/AdminUploadPanel.tsx +++ b/src/admin/upload/AdminUploadPanel.tsx @@ -1,13 +1,11 @@ 'use client'; -import { isPathAdminPhotos } from '@/app/paths'; import Container from '@/components/Container'; import LoaderButton from '@/components/primitives/LoaderButton'; import SiteGrid from '@/components/SiteGrid'; import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; import { useAppState } from '@/state/AppState'; import clsx from 'clsx'; -import { usePathname } from 'next/navigation'; import { IoCloseSharp } from 'react-icons/io5'; export default function AdminUploadPanel({ @@ -17,22 +15,18 @@ export default function AdminUploadPanel({ shouldResize: boolean onLastUpload: () => Promise }) { - const pathname = usePathname(); - const { uploadInputRef, uploadState: { isUploading, + hideUploadPanel, }, resetUploadState, } = useAppState(); return ( { + // Hide upload panel while button is shown + if (showButton) { + setUploadState?.({ hideUploadPanel: true }); + return () => { setUploadState?.({ hideUploadPanel: false }); }; + } + }, [setUploadState, showButton]); + const shouldResetUploadStateAfterPending = useRef(false); const [isPending, startTransition] = useTransition(); useEffect(() => {