From 1a273625a96b1646f227ae47c2bd216ed97aff50 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 27 Feb 2025 22:05:45 -0600 Subject: [PATCH] Finalize base multi-origin upload approach --- app/admin/photos/page.tsx | 4 +- app/layout.tsx | 11 +- src/admin/AdminAddAllUploads.tsx | 18 +- src/admin/AdminCTA.tsx | 17 +- src/admin/AdminPhotosClient.tsx | 18 +- src/admin/AdminUploadsTable.tsx | 4 +- src/admin/DeleteButton.tsx | 2 +- ...eBlobButton.tsx => DeleteUploadButton.tsx} | 26 ++- src/admin/upload/AdminUploadPanel.tsx | 132 ++------------ src/admin/upload/index.ts | 8 +- src/app/paths.ts | 3 + src/components/ImageInput.tsx | 44 ++--- src/photo/PhotoUpload.tsx | 109 ------------ src/photo/PhotoUploadWithStatus.tsx | 163 ++++++++++++++++++ src/photo/PhotosEmptyState.tsx | 12 +- src/photo/actions.ts | 4 +- src/state/AppStateProvider.tsx | 9 +- 17 files changed, 305 insertions(+), 279 deletions(-) rename src/admin/{DeleteBlobButton.tsx => DeleteUploadButton.tsx} (58%) delete mode 100644 src/photo/PhotoUpload.tsx create mode 100644 src/photo/PhotoUploadWithStatus.tsx diff --git a/app/admin/photos/page.tsx b/app/admin/photos/page.tsx index 8cc3908c..196f4fcc 100644 --- a/app/admin/photos/page.tsx +++ b/app/admin/photos/page.tsx @@ -6,6 +6,7 @@ import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone'; import { getOutdatedPhotosCount } from '@/photo/db/query'; +import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config'; export const maxDuration = 60; @@ -43,7 +44,8 @@ export default async function AdminPhotosPage() { photos, photosCount, photosCountOutdated, - onLastPhotoUpload: async () => { + shouldResize: !PRESERVE_ORIGINAL_UPLOADS, + onLastUpload: async () => { 'use server'; // Update upload count in admin nav revalidatePath('/admin', 'layout'); diff --git a/app/layout.tsx b/app/layout.tsx index f2d687ec..b4f37c38 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { clsx } from 'clsx/lite'; import { BASE_URL, DEFAULT_THEME, + PRESERVE_ORIGINAL_UPLOADS, SITE_DESCRIPTION, SITE_DOMAIN_OR_TITLE, SITE_TITLE, @@ -20,6 +21,7 @@ import SwrConfigClient from '@/state/SwrConfigClient'; import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel'; import ShareModals from '@/share/ShareModals'; import AdminUploadPanel from '@/admin/upload/AdminUploadPanel'; +import { revalidatePath } from 'next/cache'; import '../tailwind.css'; @@ -89,7 +91,14 @@ export default function RootLayout({ 'mb-12', 'space-y-5', )}> - + { + 'use server'; + // Update upload count in admin nav + revalidatePath('/admin', 'layout'); + }} + /> {children} 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 }));