diff --git a/app/api/storage/vercel-blob/route.ts b/app/api/storage/vercel-blob/route.ts index edc3cb7a..6f9f84c3 100644 --- a/app/api/storage/vercel-blob/route.ts +++ b/app/api/storage/vercel-blob/route.ts @@ -6,7 +6,6 @@ import { } from '@/photo'; import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'; import { NextResponse } from 'next/server'; -import { isUploadPathnameValid } from '@/photo/storage'; export async function POST(request: Request): Promise { const body: HandleUploadBody = await request.json(); @@ -15,18 +14,13 @@ export async function POST(request: Request): Promise { const jsonResponse = await handleUpload({ body, request, - onBeforeGenerateToken: async (pathname) => { + onBeforeGenerateToken: async () => { const session = await auth(); if (session?.user) { - if (isUploadPathnameValid(pathname)) { - return { - maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES, - allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES, - addRandomSuffix: true, - }; - } else { - throw new Error('Invalid upload'); - } + return { + maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES, + allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES, + }; } else { throw new Error('Unauthenticated upload'); } diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index a6631085..d44d1af3 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ComponentProps, useMemo } from 'react'; +import { ComponentProps, useMemo, useRef } from 'react'; import { getPathComponents, PATH_ROOT, @@ -9,6 +9,7 @@ import { } from '@/app/path'; import { deletePhotoAction, + replacePhotoStorageAction, syncPhotoAction, toggleFavoritePhotoAction, togglePrivatePhotoAction, @@ -17,6 +18,7 @@ import { Photo, deleteConfirmationTextForPhoto, downloadFileNameForPhoto, + titleForPhoto, } from '@/photo'; import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag'; import { usePathname } from 'next/navigation'; @@ -34,23 +36,32 @@ import { KEY_COMMANDS } from '@/photo/key-commands'; import { useAppText } from '@/i18n/state/client'; import IconLock from '@/components/icons/IconLock'; import IconTrash from '@/components/icons/IconTrash'; +import IconUpload from '@/components/icons/IconUpload'; +import { uploadPhotoFromClient } from '@/photo/storage'; +import ImageInput from '@/components/ImageInput'; +import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config'; export default function AdminPhotoMenu({ photo, revalidatePhoto, includeFavorite = true, showKeyCommands, + alwaysVisible, ...props -}: Omit, 'sections'> & { +}: Omit, 'sections' | 'ariaLabel'> & { photo: Photo revalidatePhoto?: RevalidatePhoto includeFavorite?: boolean showKeyCommands?: boolean + alwaysVisible?: boolean }) { const { isUserSignedIn, registerAdminUpdate } = useAppState(); const appText = useAppText(); + const inputRef = useRef(null); + const onUploadFinishRef = useRef<() => void>(null); + const path = usePathname(); const pathComponents = getPathComponents(path); const isOnPhotoDetail = pathComponents.photoId === photo.id; @@ -68,7 +79,7 @@ export default function AdminPhotoMenu({ label: appText.admin.edit, icon: , href: pathForAdminPhotoEdit(photo.id), ...showKeyCommands && { keyCommand: KEY_COMMANDS.edit }, @@ -112,8 +123,8 @@ export default function AdminPhotoMenu({ items.push({ label: appText.admin.download, icon: , href: photo.url, hrefDownloadName: downloadFileNameForPhoto(photo), @@ -137,6 +148,17 @@ export default function AdminPhotoMenu({ .then(() => revalidatePhoto?.(photo.id)), ...showKeyCommands && { keyCommand: KEY_COMMANDS.sync }, }); + items.push({ + label: appText.admin.reUpload, + icon: , + action: () => new Promise(resolve => { + onUploadFinishRef.current = resolve; + inputRef.current?.click(); + }), + }); return { items }; }, [ @@ -189,11 +211,24 @@ export default function AdminPhotoMenu({ , [sectionMain, sectionDelete]); return ( - isUserSignedIn - ? + isUserSignedIn || alwaysVisible + ? <> + + + uploadPhotoFromClient(blob, extension) + .then(updatedStorageUrl => + replacePhotoStorageAction(photo.id, updatedStorageUrl)) + .then(() => revalidatePhoto?.(photo.id)) + .finally(onUploadFinishRef.current)} + shouldResize={!PRESERVE_ORIGINAL_UPLOADS} + /> + : null ); } diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index 050922a0..f3963d22 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -12,7 +12,6 @@ import EditButton from './EditButton'; import { useAppState } from '@/app/AppState'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import PhotoSyncButton from './PhotoSyncButton'; -import DeletePhotoButton from './DeletePhotoButton'; import { Timezone } from '@/utility/timezone'; import { photoNeedsToBeUpdated } from '@/photo/update'; import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon'; @@ -20,6 +19,7 @@ import { doesPhotoHaveDefaultVisibility } from '@/photo/visibility'; import UpdateTooltip from '@/photo/update/UpdateTooltip'; import PhotoColors from '@/photo/color/PhotoColors'; import SyncColorButton from '@/photo/color/SyncColorButton'; +import AdminPhotoMenu from './AdminPhotoMenu'; export default function AdminPhotosTable({ photos, @@ -142,11 +142,12 @@ export default function AdminPhotosTable({ /> {debugColorData && } - {canDelete && - revalidatePhoto?.(photo.id, true)} - />} + )} diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index c0cfabb5..ad848b2d 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -30,6 +30,7 @@ export default function MoreMenu({ isOpen: isOpenProp, setIsOpen: setIsOpenProp, onOpen, + disabled, ...props }: { sections: MoreMenuSection[] @@ -42,6 +43,7 @@ export default function MoreMenu({ isOpen?: boolean setIsOpen?: (isOpen: boolean) => void onOpen?: () => void + disabled?: boolean } & ComponentProps){ const [isOpenInternal, setIsOpenInternal] = useState(isOpenProp ?? false); @@ -62,7 +64,7 @@ export default function MoreMenu({ open={isOpen} onOpenChange={setIsOpen} > - +