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 df6a17b5..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, @@ -19,6 +20,8 @@ import CommandK from '@/app/CommandK'; 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'; @@ -88,6 +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/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index af6714a7..4c2e5b05 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, @@ -33,6 +34,7 @@ export default function AdminAppMenu({ uploadsCount, tagsCount, selectedPhotoIds, + startUpload, setSelectedPhotoIds, refreshAdminData, clearAuthStateAndRedirect, @@ -41,6 +43,13 @@ export default function AdminAppMenu({ const isSelecting = selectedPhotoIds !== undefined; const items: ComponentProps['items'] = [{ + label: 'Upload Photos …', + icon: , + action: startUpload, + }, { label: 'Manage Photos', ...photosCount !== undefined && { annotation: `${photosCount}`, 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({ Promise +}) { const { isUserSignedIn } = useAppState(); - const [isUploading, setIsUploading] = useState(false); - return (
{isUserSignedIn - ? : Promise blobPhotoUrls: StorageListResponse + shouldResize: boolean + onLastUpload: () => Promise infiniteScrollInitial: number infiniteScrollMultiple: number timezone: Timezone }) { - const [isUploading, setIsUploading] = useState(false); + const { uploadState: { isUploading } } = useAppState(); return ( -
+
-
{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 new file mode 100644 index 00000000..7be0a887 --- /dev/null +++ b/src/admin/upload/AdminUploadPanel.tsx @@ -0,0 +1,56 @@ +'use client'; + +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 { IoCloseSharp } from 'react-icons/io5'; + +export default function AdminUploadPanel({ + shouldResize, + onLastUpload, +}: { + shouldResize: boolean + onLastUpload: () => Promise +}) { + const { + uploadInputRef, + uploadState: { + isUploading, + hideUploadPanel, + }, + resetUploadState, + } = useAppState(); + + return ( + +
+ + } + onClick={resetUploadState} + /> +
+ } + /> + ); +} diff --git a/src/admin/upload/index.ts b/src/admin/upload/index.ts new file mode 100644 index 00000000..c083e22e --- /dev/null +++ b/src/admin/upload/index.ts @@ -0,0 +1,19 @@ +export interface UploadState { + isUploading: boolean + uploadError: string + debugDownload?: { href: string, fileName: string } + image?: HTMLImageElement + hideUploadPanel?: boolean + fileUploadName: string + fileUploadIndex: number + filesLength: number +} + +export const INITIAL_UPLOAD_STATE: UploadState = { + isUploading: false, + uploadError: '', + hideUploadPanel: false, + fileUploadName: '', + fileUploadIndex: 0, + 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 3bac3e98..a1e716e4 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, RefObject } from 'react'; import { CopyExif } from '@/lib/CopyExif'; import exifr from 'exifr'; import { clsx } from 'clsx/lite'; @@ -9,19 +9,22 @@ 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'; - -const INPUT_ID = 'file'; +import { useAppState } from '@/state/AppState'; export default function ImageInput({ + ref: inputRefExternal, + id = 'file', onStart, onBlobReady, shouldResize, maxSize = MAX_IMAGE_SIZE, quality = 0.8, - loading, - showUploadStatus = true, + showButton, + disabled: disabledProp, debug, }: { + ref?: RefObject + id?: string onStart?: () => void onBlobReady?: (args: { blob: Blob, @@ -32,66 +35,79 @@ export default function ImageInput({ shouldResize?: boolean maxSize?: number quality?: number - loading?: boolean - showUploadStatus?: boolean + showButton?: boolean + disabled?: boolean debug?: boolean }) { - const inputRef = useRef(null); + const inputRefInternal = 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 inputRef = inputRefExternal ?? inputRefInternal; + + const { + uploadState: { + isUploading, + image, + filesLength, + fileUploadIndex, + }, + setUploadState, + resetUploadState, + } = useAppState(); + + const disabled = disabledProp || isUploading; return ( -
+
- {showUploadStatus && filesLength > 0 && -
- {fileUploadName} -
}
{ - 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 deleted file mode 100644 index 1e1b7717..00000000 --- a/src/photo/PhotoUpload.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'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'; - -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 router = useRouter(); - - return ( -
-
-
- { - setIsUploading(true); - setUploadError(''); - }} - onBlobReady={async ({ - blob, - extension, - hasMultipleUploads, - isLastBlob, - }) => { - if (debug) { - setDebugDownload({ - href: URL.createObjectURL(blob), - fileName: `debug.${extension}`, - }); - setIsUploading(false); - setUploadError(''); - } else { - return uploadPhotoFromClient( - blob, - extension, - ) - .then(async url => { - if (isLastBlob) { - await onLastUpload?.(); - if (hasMultipleUploads) { - // Redirect to view multiple uploads - router.push(PATH_ADMIN_UPLOADS); - } else { - // Redirect to photo detail page - router.push(pathForAdminUploadUrl(url)); - } - } - }) - .catch(error => { - setIsUploading(false); - setUploadError(`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..2ea6b83c --- /dev/null +++ b/src/photo/PhotoUploadWithStatus.tsx @@ -0,0 +1,171 @@ +'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(); + + useEffect(() => { + // 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(() => { + 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/AppState.ts b/src/state/AppState.ts index c0091658..4ed162a7 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -1,7 +1,14 @@ -import { Dispatch, SetStateAction, createContext, useContext } from 'react'; +import { + Dispatch, + SetStateAction, + createContext, + useContext, + RefObject, +} 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 +22,12 @@ export interface AppStateContext { clearNextPhotoAnimation?: () => void shouldRespondToKeyboardCommands?: boolean setShouldRespondToKeyboardCommands?: Dispatch> + // UPLOAD + startUpload?: () => void + uploadInputRef?: RefObject + uploadState: UploadState + setUploadState?: (uploadState: Partial) => void + resetUploadState?: () => void // MODAL isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> @@ -57,6 +70,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..07c73742 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, ReactNode, useCallback } from 'react'; +import { useState, useEffect, ReactNode, useCallback, useRef } from 'react'; import { AppStateContext } from './AppState'; import { AnimationConfig } from '@/components/AnimateItems'; import usePathnames from '@/utility/usePathnames'; @@ -23,16 +23,17 @@ 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, }: { children: ReactNode }) { - const { previousPathname } = usePathnames(); - const router = useRouter(); + const { previousPathname } = usePathnames(); + // CORE const [hasLoaded, setHasLoaded] = useState(false); @@ -42,12 +43,16 @@ export default function AppStateProvider({ useState(); const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] = useState(true); + // UPLOAD + const uploadInputRef = useRef(null); + 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 +90,19 @@ export default function AppStateProvider({ const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] = useState(false); + const startUpload = useCallback(() => { + if (uploadInputRef.current) { + uploadInputRef.current.value = ''; + uploadInputRef.current.click(); + } + }, []); + const setUploadState = useCallback((uploadState: Partial) => { + _setUploadState(prev => ({ ...prev, ...uploadState })); + }, []); + const resetUploadState = useCallback(() => { + _setUploadState(INITIAL_UPLOAD_STATE); + }, []); + const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); const { data: auth, error: authError } = useSWR('getAuth', getAuthAction); @@ -136,6 +154,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 +173,12 @@ export default function AppStateProvider({ clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands, + // UPLOAD + uploadInputRef, + startUpload, + uploadState, + setUploadState, + resetUploadState, // MODAL isCommandKOpen, setIsCommandKOpen,