Replace backing storage (#374)
* Centralize random suffix generation * Introduce ••• menu in admin photos table * Finalize re-upload behavior * Finalize re-upload locales * Honor resize config when re-uploading files
This commit is contained in:
parent
f962074af9
commit
351f8869a6
@ -6,7 +6,6 @@ import {
|
|||||||
} from '@/photo';
|
} from '@/photo';
|
||||||
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { isUploadPathnameValid } from '@/photo/storage';
|
|
||||||
|
|
||||||
export async function POST(request: Request): Promise<NextResponse> {
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
const body: HandleUploadBody = await request.json();
|
const body: HandleUploadBody = await request.json();
|
||||||
@ -15,18 +14,13 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
const jsonResponse = await handleUpload({
|
const jsonResponse = await handleUpload({
|
||||||
body,
|
body,
|
||||||
request,
|
request,
|
||||||
onBeforeGenerateToken: async (pathname) => {
|
onBeforeGenerateToken: async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
if (isUploadPathnameValid(pathname)) {
|
|
||||||
return {
|
return {
|
||||||
maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
|
maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
|
||||||
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
|
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
|
||||||
addRandomSuffix: true,
|
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
throw new Error('Invalid upload');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Unauthenticated upload');
|
throw new Error('Unauthenticated upload');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ComponentProps, useMemo } from 'react';
|
import { ComponentProps, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
getPathComponents,
|
getPathComponents,
|
||||||
PATH_ROOT,
|
PATH_ROOT,
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from '@/app/path';
|
} from '@/app/path';
|
||||||
import {
|
import {
|
||||||
deletePhotoAction,
|
deletePhotoAction,
|
||||||
|
replacePhotoStorageAction,
|
||||||
syncPhotoAction,
|
syncPhotoAction,
|
||||||
toggleFavoritePhotoAction,
|
toggleFavoritePhotoAction,
|
||||||
togglePrivatePhotoAction,
|
togglePrivatePhotoAction,
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
Photo,
|
Photo,
|
||||||
deleteConfirmationTextForPhoto,
|
deleteConfirmationTextForPhoto,
|
||||||
downloadFileNameForPhoto,
|
downloadFileNameForPhoto,
|
||||||
|
titleForPhoto,
|
||||||
} from '@/photo';
|
} from '@/photo';
|
||||||
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
|
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
@ -34,23 +36,32 @@ import { KEY_COMMANDS } from '@/photo/key-commands';
|
|||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
import IconLock from '@/components/icons/IconLock';
|
import IconLock from '@/components/icons/IconLock';
|
||||||
import IconTrash from '@/components/icons/IconTrash';
|
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({
|
export default function AdminPhotoMenu({
|
||||||
photo,
|
photo,
|
||||||
revalidatePhoto,
|
revalidatePhoto,
|
||||||
includeFavorite = true,
|
includeFavorite = true,
|
||||||
showKeyCommands,
|
showKeyCommands,
|
||||||
|
alwaysVisible,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof MoreMenu>, 'sections'> & {
|
}: Omit<ComponentProps<typeof MoreMenu>, 'sections' | 'ariaLabel'> & {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
revalidatePhoto?: RevalidatePhoto
|
revalidatePhoto?: RevalidatePhoto
|
||||||
includeFavorite?: boolean
|
includeFavorite?: boolean
|
||||||
showKeyCommands?: boolean
|
showKeyCommands?: boolean
|
||||||
|
alwaysVisible?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { isUserSignedIn, registerAdminUpdate } = useAppState();
|
const { isUserSignedIn, registerAdminUpdate } = useAppState();
|
||||||
|
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const onUploadFinishRef = useRef<() => void>(null);
|
||||||
|
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const pathComponents = getPathComponents(path);
|
const pathComponents = getPathComponents(path);
|
||||||
const isOnPhotoDetail = pathComponents.photoId === photo.id;
|
const isOnPhotoDetail = pathComponents.photoId === photo.id;
|
||||||
@ -68,7 +79,7 @@ export default function AdminPhotoMenu({
|
|||||||
label: appText.admin.edit,
|
label: appText.admin.edit,
|
||||||
icon: <IconEdit
|
icon: <IconEdit
|
||||||
size={14}
|
size={14}
|
||||||
className="translate-x-[0.5px] translate-y-[0.5px]"
|
className="translate-x-[1px] translate-y-[-0.5px]"
|
||||||
/>,
|
/>,
|
||||||
href: pathForAdminPhotoEdit(photo.id),
|
href: pathForAdminPhotoEdit(photo.id),
|
||||||
...showKeyCommands && { keyCommand: KEY_COMMANDS.edit },
|
...showKeyCommands && { keyCommand: KEY_COMMANDS.edit },
|
||||||
@ -112,8 +123,8 @@ export default function AdminPhotoMenu({
|
|||||||
items.push({
|
items.push({
|
||||||
label: appText.admin.download,
|
label: appText.admin.download,
|
||||||
icon: <MdOutlineFileDownload
|
icon: <MdOutlineFileDownload
|
||||||
size={17}
|
size={18}
|
||||||
className="translate-x-[-1px]"
|
className="translate-x-[-1.5px]"
|
||||||
/>,
|
/>,
|
||||||
href: photo.url,
|
href: photo.url,
|
||||||
hrefDownloadName: downloadFileNameForPhoto(photo),
|
hrefDownloadName: downloadFileNameForPhoto(photo),
|
||||||
@ -137,6 +148,17 @@ export default function AdminPhotoMenu({
|
|||||||
.then(() => revalidatePhoto?.(photo.id)),
|
.then(() => revalidatePhoto?.(photo.id)),
|
||||||
...showKeyCommands && { keyCommand: KEY_COMMANDS.sync },
|
...showKeyCommands && { keyCommand: KEY_COMMANDS.sync },
|
||||||
});
|
});
|
||||||
|
items.push({
|
||||||
|
label: appText.admin.reUpload,
|
||||||
|
icon: <IconUpload
|
||||||
|
size={16}
|
||||||
|
className="translate-x-[-1px] translate-y-px"
|
||||||
|
/>,
|
||||||
|
action: () => new Promise(resolve => {
|
||||||
|
onUploadFinishRef.current = resolve;
|
||||||
|
inputRef.current?.click();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
}, [
|
}, [
|
||||||
@ -189,11 +211,24 @@ export default function AdminPhotoMenu({
|
|||||||
, [sectionMain, sectionDelete]);
|
, [sectionMain, sectionDelete]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isUserSignedIn
|
isUserSignedIn || alwaysVisible
|
||||||
? <MoreMenu {...{
|
? <>
|
||||||
|
<MoreMenu {...{
|
||||||
...props,
|
...props,
|
||||||
sections,
|
sections,
|
||||||
|
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
|
||||||
}}/>
|
}}/>
|
||||||
|
<ImageInput
|
||||||
|
ref={inputRef}
|
||||||
|
onBlobReady={async ({ blob, extension }) =>
|
||||||
|
uploadPhotoFromClient(blob, extension)
|
||||||
|
.then(updatedStorageUrl =>
|
||||||
|
replacePhotoStorageAction(photo.id, updatedStorageUrl))
|
||||||
|
.then(() => revalidatePhoto?.(photo.id))
|
||||||
|
.finally(onUploadFinishRef.current)}
|
||||||
|
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import EditButton from './EditButton';
|
|||||||
import { useAppState } from '@/app/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||||
import PhotoSyncButton from './PhotoSyncButton';
|
import PhotoSyncButton from './PhotoSyncButton';
|
||||||
import DeletePhotoButton from './DeletePhotoButton';
|
|
||||||
import { Timezone } from '@/utility/timezone';
|
import { Timezone } from '@/utility/timezone';
|
||||||
import { photoNeedsToBeUpdated } from '@/photo/update';
|
import { photoNeedsToBeUpdated } from '@/photo/update';
|
||||||
import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon';
|
import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon';
|
||||||
@ -20,6 +19,7 @@ import { doesPhotoHaveDefaultVisibility } from '@/photo/visibility';
|
|||||||
import UpdateTooltip from '@/photo/update/UpdateTooltip';
|
import UpdateTooltip from '@/photo/update/UpdateTooltip';
|
||||||
import PhotoColors from '@/photo/color/PhotoColors';
|
import PhotoColors from '@/photo/color/PhotoColors';
|
||||||
import SyncColorButton from '@/photo/color/SyncColorButton';
|
import SyncColorButton from '@/photo/color/SyncColorButton';
|
||||||
|
import AdminPhotoMenu from './AdminPhotoMenu';
|
||||||
|
|
||||||
export default function AdminPhotosTable({
|
export default function AdminPhotosTable({
|
||||||
photos,
|
photos,
|
||||||
@ -142,11 +142,12 @@ export default function AdminPhotosTable({
|
|||||||
/>
|
/>
|
||||||
{debugColorData &&
|
{debugColorData &&
|
||||||
<SyncColorButton photoId={photo.id} />}
|
<SyncColorButton photoId={photo.id} />}
|
||||||
{canDelete &&
|
<AdminPhotoMenu
|
||||||
<DeletePhotoButton
|
|
||||||
photo={photo}
|
photo={photo}
|
||||||
onDelete={() => revalidatePhoto?.(photo.id, true)}
|
revalidatePhoto={revalidatePhoto}
|
||||||
/>}
|
disabled={!canEdit || !canDelete}
|
||||||
|
alwaysVisible
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>)}
|
</Fragment>)}
|
||||||
</AdminTable>
|
</AdminTable>
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export default function MoreMenu({
|
|||||||
isOpen: isOpenProp,
|
isOpen: isOpenProp,
|
||||||
setIsOpen: setIsOpenProp,
|
setIsOpen: setIsOpenProp,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
sections: MoreMenuSection[]
|
sections: MoreMenuSection[]
|
||||||
@ -42,6 +43,7 @@ export default function MoreMenu({
|
|||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
setIsOpen?: (isOpen: boolean) => void
|
setIsOpen?: (isOpen: boolean) => void
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
disabled?: boolean
|
||||||
} & ComponentProps<typeof DropdownMenu.Content>){
|
} & ComponentProps<typeof DropdownMenu.Content>){
|
||||||
const [isOpenInternal, setIsOpenInternal] = useState(isOpenProp ?? false);
|
const [isOpenInternal, setIsOpenInternal] = useState(isOpenProp ?? false);
|
||||||
|
|
||||||
@ -62,7 +64,7 @@ export default function MoreMenu({
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild {...{ disabled }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'সর্বজনীন করুন',
|
public: 'সর্বজনীন করুন',
|
||||||
download: 'ডাউনলোড',
|
download: 'ডাউনলোড',
|
||||||
sync: 'সিঙ্ক',
|
sync: 'সিঙ্ক',
|
||||||
|
reUpload: 'পুনরায় আপলোড করুন',
|
||||||
delete: 'ডিলিট',
|
delete: 'ডিলিট',
|
||||||
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
|
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'Make Public',
|
public: 'Make Public',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
sync: 'Sync',
|
sync: 'Sync',
|
||||||
|
reUpload: 'Re-upload',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',
|
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export const TEXT = {
|
|||||||
public: 'Make Public',
|
public: 'Make Public',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
sync: 'Sync',
|
sync: 'Sync',
|
||||||
|
reUpload: 'Re-upload',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',
|
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'सार्वजनिक बनाएं',
|
public: 'सार्वजनिक बनाएं',
|
||||||
download: 'डाउनलोड करें',
|
download: 'डाउनलोड करें',
|
||||||
sync: 'सिंक करें',
|
sync: 'सिंक करें',
|
||||||
|
reUpload: 'पुनः अपलोड करें',
|
||||||
delete: 'हटाएं',
|
delete: 'हटाएं',
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
deleteConfirm: 'क्या आप सुनिश्चित हैं कि "{{photoTitle}}" को हटाना चाहते हैं?',
|
deleteConfirm: 'क्या आप सुनिश्चित हैं कि "{{photoTitle}}" को हटाना चाहते हैं?',
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'Buat Publik',
|
public: 'Buat Publik',
|
||||||
download: 'Unduh',
|
download: 'Unduh',
|
||||||
sync: 'Sinkronkan',
|
sync: 'Sinkronkan',
|
||||||
|
reUpload: 'Unggah ulang',
|
||||||
delete: 'Hapus',
|
delete: 'Hapus',
|
||||||
deleteConfirm: 'Apakah Anda yakin ingin menghapus "{{photoTitle}}"?',
|
deleteConfirm: 'Apakah Anda yakin ingin menghapus "{{photoTitle}}"?',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'Tornar Público',
|
public: 'Tornar Público',
|
||||||
download: 'Baixar',
|
download: 'Baixar',
|
||||||
sync: 'Sincronizar',
|
sync: 'Sincronizar',
|
||||||
|
reUpload: 'Enviar novamente',
|
||||||
delete: 'Excluir',
|
delete: 'Excluir',
|
||||||
deleteConfirm: 'Tem certeza de que deseja excluir "{{photoTitle}}"?',
|
deleteConfirm: 'Tem certeza de que deseja excluir "{{photoTitle}}"?',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'Tornar Público',
|
public: 'Tornar Público',
|
||||||
download: 'Descarregar',
|
download: 'Descarregar',
|
||||||
sync: 'Sincronizar',
|
sync: 'Sincronizar',
|
||||||
|
reUpload: 'Carregar novamente',
|
||||||
delete: 'Excluir',
|
delete: 'Excluir',
|
||||||
deleteConfirm: 'Tens certeza de que deseja excluir "{{photoTitle}}"?',
|
deleteConfirm: 'Tens certeza de que deseja excluir "{{photoTitle}}"?',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: 'Herkese Açık Yap',
|
public: 'Herkese Açık Yap',
|
||||||
download: 'İndir',
|
download: 'İndir',
|
||||||
sync: 'Senkronize Et',
|
sync: 'Senkronize Et',
|
||||||
|
reUpload: 'Yeniden Yükle',
|
||||||
delete: 'Sil',
|
delete: 'Sil',
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
deleteConfirm: '"{{photoTitle}}" adlı fotoğrafı silmek istediğinize emin misiniz?',
|
deleteConfirm: '"{{photoTitle}}" adlı fotoğrafı silmek istediğinize emin misiniz?',
|
||||||
|
|||||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
|||||||
public: '设为公开',
|
public: '设为公开',
|
||||||
download: '下载',
|
download: '下载',
|
||||||
sync: '同步',
|
sync: '同步',
|
||||||
|
reUpload: '重新上传',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?',
|
deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
shouldShowFilmDataForPhoto,
|
shouldShowFilmDataForPhoto,
|
||||||
shouldShowLensDataForPhoto,
|
shouldShowLensDataForPhoto,
|
||||||
shouldShowRecipeDataForPhoto,
|
shouldShowRecipeDataForPhoto,
|
||||||
titleForPhoto,
|
|
||||||
} from '.';
|
} from '.';
|
||||||
import AppGrid from '@/components/AppGrid';
|
import AppGrid from '@/components/AppGrid';
|
||||||
import ImageLarge from '@/components/image/ImageLarge';
|
import ImageLarge from '@/components/image/ImageLarge';
|
||||||
@ -260,7 +259,6 @@ export default function PhotoLarge({
|
|||||||
photo,
|
photo,
|
||||||
revalidatePhoto,
|
revalidatePhoto,
|
||||||
includeFavorite: includeFavoriteInAdminMenu,
|
includeFavorite: includeFavoriteInAdminMenu,
|
||||||
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
|
|
||||||
showKeyCommands: showAdminKeyCommands,
|
showKeyCommands: showAdminKeyCommands,
|
||||||
}} />;
|
}} />;
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { RefObject, useTransition, useRef, useEffect } from 'react';
|
|||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
import ResponsiveText from '@/components/primitives/ResponsiveText';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
import { uploadPhotoFromClient } from './storage';
|
import { uploadTempPhotoFromClient } from './storage';
|
||||||
|
|
||||||
export default function PhotoUploadWithStatus({
|
export default function PhotoUploadWithStatus({
|
||||||
inputRef,
|
inputRef,
|
||||||
@ -119,7 +119,7 @@ export default function PhotoUploadWithStatus({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return uploadPhotoFromClient(
|
return uploadTempPhotoFromClient(
|
||||||
blob,
|
blob,
|
||||||
extension,
|
extension,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -22,7 +22,10 @@ import {
|
|||||||
convertPhotoToFormData,
|
convertPhotoToFormData,
|
||||||
} from './form';
|
} from './form';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { deleteFile } from '@/platforms/storage';
|
import {
|
||||||
|
deleteFile,
|
||||||
|
getFileNamePartsFromStorageUrl,
|
||||||
|
} from '@/platforms/storage';
|
||||||
import {
|
import {
|
||||||
revalidateAdminPaths,
|
revalidateAdminPaths,
|
||||||
revalidateAllKeysAndPaths,
|
revalidateAllKeysAndPaths,
|
||||||
@ -58,7 +61,10 @@ import {
|
|||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import { generateAiImageQueries } from './ai/server';
|
import { generateAiImageQueries } from './ai/server';
|
||||||
import { createStreamableValue } from '@ai-sdk/rsc';
|
import { createStreamableValue } from '@ai-sdk/rsc';
|
||||||
import { convertUploadToPhoto } from './storage/server';
|
import {
|
||||||
|
convertUploadToPhoto,
|
||||||
|
storeOptimizedPhotosForUrl,
|
||||||
|
} from './storage/server';
|
||||||
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
|
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
|
||||||
import { convertStringToArray } from '@/utility/string';
|
import { convertStringToArray } from '@/utility/string';
|
||||||
import { after } from 'next/server';
|
import { after } from 'next/server';
|
||||||
@ -74,6 +80,7 @@ import {
|
|||||||
upgradeTagToAlbum,
|
upgradeTagToAlbum,
|
||||||
} from '@/album/server';
|
} from '@/album/server';
|
||||||
import { addPhotoAlbumIds } from '@/album/query';
|
import { addPhotoAlbumIds } from '@/album/query';
|
||||||
|
import { getStorageUrlsForPhoto } from './storage';
|
||||||
|
|
||||||
// Private actions
|
// Private actions
|
||||||
|
|
||||||
@ -512,6 +519,29 @@ export const renamePhotoRecipeGloballyAction = async (formData: FormData) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const replacePhotoStorageAction = async (
|
||||||
|
photoId: string,
|
||||||
|
updatedStorageUrl: string,
|
||||||
|
) =>
|
||||||
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
|
const photo = await getPhoto(photoId, true);
|
||||||
|
if (photo) {
|
||||||
|
const {
|
||||||
|
fileExtension: extension,
|
||||||
|
} = getFileNamePartsFromStorageUrl(updatedStorageUrl);
|
||||||
|
await updatePhoto(convertPhotoToPhotoDbInsert({
|
||||||
|
...photo,
|
||||||
|
url: updatedStorageUrl,
|
||||||
|
extension,
|
||||||
|
}));
|
||||||
|
await storeOptimizedPhotosForUrl(updatedStorageUrl);
|
||||||
|
const existingStorageUrls = await getStorageUrlsForPhoto(photo)
|
||||||
|
.then(urls => urls.map(({ url }) => url));
|
||||||
|
await Promise.all(existingStorageUrls.map(deleteFile));
|
||||||
|
revalidatePhoto(photo.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const deleteUploadsAction = async (urls: string[]) =>
|
export const deleteUploadsAction = async (urls: string[]) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
await Promise.all(urls.map(url => deleteFile(url)));
|
await Promise.all(urls.map(url => deleteFile(url)));
|
||||||
|
|||||||
@ -72,7 +72,7 @@ const THUMBNAIL_SIZE = 300;
|
|||||||
export default function PhotoForm({
|
export default function PhotoForm({
|
||||||
type = 'create',
|
type = 'create',
|
||||||
initialPhotoForm,
|
initialPhotoForm,
|
||||||
photoStorageUrls,
|
photoStorageUrls = [],
|
||||||
updatedExifData,
|
updatedExifData,
|
||||||
updatedBlurData,
|
updatedBlurData,
|
||||||
photoAlbumTitles = [],
|
photoAlbumTitles = [],
|
||||||
@ -291,7 +291,11 @@ export default function PhotoForm({
|
|||||||
const footerForField = (key: keyof PhotoFormData) => {
|
const footerForField = (key: keyof PhotoFormData) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'url':
|
case 'url':
|
||||||
return photoStorageUrls && photoStorageUrls.length > 1
|
return photoStorageUrls.length === 0
|
||||||
|
? <span className="text-error">
|
||||||
|
No storage found for photo
|
||||||
|
</span>
|
||||||
|
: photoStorageUrls.length > 1
|
||||||
? <SmallDisclosure label="Optimized file set">
|
? <SmallDisclosure label="Optimized file set">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{photoStorageUrls.map(({ url, size }) => {
|
{photoStorageUrls.map(({ url, size }) => {
|
||||||
@ -320,7 +324,7 @@ export default function PhotoForm({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SmallDisclosure>
|
</SmallDisclosure>
|
||||||
: undefined;
|
: null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -71,9 +71,6 @@ export const getOptimizedUrlsFromPhotoUrl = (url: string) => {
|
|||||||
`${urlBase}/${fileName}`);
|
`${urlBase}/${fileName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isUploadPathnameValid = (pathname?: string) =>
|
|
||||||
pathname?.match(new RegExp(`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`, 'i'));
|
|
||||||
|
|
||||||
export const generateRandomFileNameForPhoto = () =>
|
export const generateRandomFileNameForPhoto = () =>
|
||||||
generateFileNameWithId(PREFIX_PHOTO);
|
generateFileNameWithId(PREFIX_PHOTO);
|
||||||
|
|
||||||
@ -83,12 +80,18 @@ export const getStorageUploadUrls = () =>
|
|||||||
export const getStoragePhotoUrls = () =>
|
export const getStoragePhotoUrls = () =>
|
||||||
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
||||||
|
|
||||||
export const uploadPhotoFromClient = (
|
export const uploadTempPhotoFromClient = (
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
extension = EXTENSION_DEFAULT,
|
extension = EXTENSION_DEFAULT,
|
||||||
) =>
|
) =>
|
||||||
uploadFileFromClient(file, PREFIX_UPLOAD, extension);
|
uploadFileFromClient(file, PREFIX_UPLOAD, extension);
|
||||||
|
|
||||||
|
export const uploadPhotoFromClient = (
|
||||||
|
file: File | Blob,
|
||||||
|
extension = EXTENSION_DEFAULT,
|
||||||
|
) =>
|
||||||
|
uploadFileFromClient(file, PREFIX_PHOTO, extension);
|
||||||
|
|
||||||
const getSuffixFromNextImageSize = (nextSize: NextImageSize) =>
|
const getSuffixFromNextImageSize = (nextSize: NextImageSize) =>
|
||||||
OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix
|
OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix
|
||||||
?? OPTIMIZED_SUFFIX_DEFAULT;
|
?? OPTIMIZED_SUFFIX_DEFAULT;
|
||||||
|
|||||||
@ -11,10 +11,13 @@ import {
|
|||||||
getOptimizedPhotoFileMeta,
|
getOptimizedPhotoFileMeta,
|
||||||
} from '.';
|
} from '.';
|
||||||
|
|
||||||
export const storeOptimizedPhotos = async (
|
export const storeOptimizedPhotosForUrl = async (
|
||||||
url: string,
|
url: string,
|
||||||
fileBytes: ArrayBuffer,
|
_fileBytes?: ArrayBuffer,
|
||||||
) => {
|
) => {
|
||||||
|
const fileBytes = _fileBytes
|
||||||
|
? _fileBytes
|
||||||
|
: await fetch(url).then(res => res.arrayBuffer());
|
||||||
const { fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
const { fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
||||||
const optimizedPhotoFileMeta = getOptimizedPhotoFileMeta(fileNameBase);
|
const optimizedPhotoFileMeta = getOptimizedPhotoFileMeta(fileNameBase);
|
||||||
for (const { fileName, size, quality } of optimizedPhotoFileMeta) {
|
for (const { fileName, size, quality } of optimizedPhotoFileMeta) {
|
||||||
@ -55,7 +58,7 @@ export const convertUploadToPhoto = async ({
|
|||||||
}
|
}
|
||||||
// Store optimized photos after original photo is copied/moved
|
// Store optimized photos after original photo is copied/moved
|
||||||
const updatedUrl = await promise
|
const updatedUrl = await promise
|
||||||
.then(async url => storeOptimizedPhotos(url, fileBytes));
|
.then(async url => storeOptimizedPhotosForUrl(url, fileBytes));
|
||||||
|
|
||||||
return updatedUrl;
|
return updatedUrl;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -114,32 +114,33 @@ export const storageTypeFromUrl = (url: string): StorageType => {
|
|||||||
|
|
||||||
export const uploadFromClientViaPresignedUrl = async (
|
export const uploadFromClientViaPresignedUrl = async (
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
fileNameBase: string,
|
fileName: string,
|
||||||
extension: string,
|
|
||||||
addRandomSuffix?: boolean,
|
|
||||||
) => {
|
) => {
|
||||||
const key = addRandomSuffix
|
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${fileName}`)
|
||||||
? `${fileNameBase}-${generateStorageId()}.${extension}`
|
|
||||||
: `${fileNameBase}.${extension}`;
|
|
||||||
|
|
||||||
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`)
|
|
||||||
.then((response) => response.text());
|
.then((response) => response.text());
|
||||||
|
|
||||||
return fetch(url, { method: 'PUT', body: file })
|
return fetch(url, { method: 'PUT', body: file })
|
||||||
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`);
|
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${fileName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadFileFromClient = async (
|
export const uploadFileFromClient = async (
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
fileNameBase: string,
|
_fileName: string,
|
||||||
extension: string,
|
extension: string,
|
||||||
) => (
|
addRandomSuffix = true,
|
||||||
|
) => {
|
||||||
|
const fileName = addRandomSuffix
|
||||||
|
? `${_fileName}-${generateStorageId()}.${extension}`
|
||||||
|
: `${_fileName}.${extension}`;
|
||||||
|
|
||||||
|
return (
|
||||||
CURRENT_STORAGE === 'cloudflare-r2' ||
|
CURRENT_STORAGE === 'cloudflare-r2' ||
|
||||||
CURRENT_STORAGE === 'aws-s3' ||
|
CURRENT_STORAGE === 'aws-s3' ||
|
||||||
CURRENT_STORAGE === 'minio'
|
CURRENT_STORAGE === 'minio'
|
||||||
)
|
)
|
||||||
? uploadFromClientViaPresignedUrl(file, fileNameBase, extension, true)
|
? uploadFromClientViaPresignedUrl(file, fileName)
|
||||||
: vercelBlobUploadFromClient(file, `${fileNameBase}.${extension}`);
|
: vercelBlobUploadFromClient(file, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
export const putFile = (
|
export const putFile = (
|
||||||
file: Buffer,
|
file: Buffer,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user