Store optimized assets for all photos (#304)
* Begin storing optimized photo files * Increase optimized image size to 1080 * Refactor photo/storage modules * Refine storage file naming api * Simplify photo storage api * Finalize photo storage api * Start storing/serving optimized photos * Finalize optimized photo asset generation * Temporarily allow static optimization on PREVIEW branches * Restore static optimization as production-only * Remove og image inline-flex class * Tweak convert upload signature * Refactor optimized file storage * Display optimized files when they exist in photo form * Create small disclosure component * Report photo storage files more accurately * Sort optimized files * Generate optimized storage files when updating/syncing photos * Include source bucket when copying files with MinIO * Make deleting files more resilient
This commit is contained in:
parent
784c641174
commit
b8c01492b8
@ -13,7 +13,10 @@ import {
|
|||||||
IS_PREVIEW,
|
IS_PREVIEW,
|
||||||
} from '@/app/config';
|
} from '@/app/config';
|
||||||
import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server';
|
import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server';
|
||||||
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
import {
|
||||||
|
getOptimizedPhotoUrlForManipulation,
|
||||||
|
getStorageUrlsForPhoto,
|
||||||
|
} from '@/photo/storage';
|
||||||
|
|
||||||
export default async function PhotoEditPage({
|
export default async function PhotoEditPage({
|
||||||
params,
|
params,
|
||||||
@ -36,24 +39,27 @@ export default async function PhotoEditPage({
|
|||||||
|
|
||||||
if (!photo) { redirect(PATH_ADMIN); }
|
if (!photo) { redirect(PATH_ADMIN); }
|
||||||
|
|
||||||
|
const photoStorageUrls = await getStorageUrlsForPhoto(photo);
|
||||||
|
|
||||||
const hasAiTextGeneration = AI_CONTENT_GENERATION_ENABLED;
|
const hasAiTextGeneration = AI_CONTENT_GENERATION_ENABLED;
|
||||||
|
|
||||||
// Only generate image thumbnails when AI generation is enabled
|
// Only generate image thumbnails when AI generation is enabled
|
||||||
const imageThumbnailBase64 = AI_CONTENT_GENERATION_ENABLED
|
const imageThumbnailBase64 = AI_CONTENT_GENERATION_ENABLED
|
||||||
? await resizeImageFromUrl(
|
? await resizeImageFromUrl(
|
||||||
getNextImageUrlForManipulation(photo.url, IS_PREVIEW),
|
getOptimizedPhotoUrlForManipulation(photo.url, IS_PREVIEW),
|
||||||
)
|
)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const blurData = BLUR_ENABLED
|
const blurData = BLUR_ENABLED
|
||||||
? await blurImageFromUrl(
|
? await blurImageFromUrl(
|
||||||
getNextImageUrlForManipulation(photo.url, IS_PREVIEW),
|
getOptimizedPhotoUrlForManipulation(photo.url, IS_PREVIEW),
|
||||||
)
|
)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PhotoEditPageClient {...{
|
<PhotoEditPageClient {...{
|
||||||
photo,
|
photo,
|
||||||
|
photoStorageUrls,
|
||||||
uniqueTags,
|
uniqueTags,
|
||||||
uniqueRecipes,
|
uniqueRecipes,
|
||||||
uniqueFilms,
|
uniqueFilms,
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import {
|
|||||||
ACCEPTED_PHOTO_FILE_TYPES,
|
ACCEPTED_PHOTO_FILE_TYPES,
|
||||||
MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
|
MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
|
||||||
} from '@/photo';
|
} from '@/photo';
|
||||||
import { isUploadPathnameValid } from '@/platforms/storage';
|
|
||||||
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();
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import ImageMedium from '@/components/image/ImageMedium';
|
import ImageMedium from '@/components/image/ImageMedium';
|
||||||
import { UrlAddStatus } from './AdminUploadsClient';
|
import { UrlAddStatus } from './AdminUploadsClient';
|
||||||
import {
|
|
||||||
getExtensionFromStorageUrl,
|
|
||||||
getIdFromStorageUrl,
|
|
||||||
} from '@/platforms/storage';
|
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
import ResponsiveDate from '@/components/ResponsiveDate';
|
import ResponsiveDate from '@/components/ResponsiveDate';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
@ -15,6 +11,7 @@ import { isElementEntirelyInViewport } from '@/utility/dom';
|
|||||||
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
import FieldsetWithStatus from '@/components/FieldsetWithStatus';
|
||||||
import EditButton from './EditButton';
|
import EditButton from './EditButton';
|
||||||
import AddUploadButton from './AddUploadButton';
|
import AddUploadButton from './AddUploadButton';
|
||||||
|
import { getFileNamePartsFromStorageUrl } from '@/platforms/storage';
|
||||||
|
|
||||||
export default function AdminUploadsTableRow({
|
export default function AdminUploadsTableRow({
|
||||||
url,
|
url,
|
||||||
@ -41,7 +38,12 @@ export default function AdminUploadsTableRow({
|
|||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const extension = getExtensionFromStorageUrl(url)?.toUpperCase();
|
const {
|
||||||
|
fileExtension,
|
||||||
|
fileId,
|
||||||
|
} = getFileNamePartsFromStorageUrl(url);
|
||||||
|
|
||||||
|
const extension = fileExtension?.toUpperCase();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -86,7 +88,7 @@ export default function AdminUploadsTableRow({
|
|||||||
'transition-transform',
|
'transition-transform',
|
||||||
)}>
|
)}>
|
||||||
<ImageMedium
|
<ImageMedium
|
||||||
title={getIdFromStorageUrl(url)}
|
title={fileId}
|
||||||
src={url}
|
src={url}
|
||||||
alt={url}
|
alt={url}
|
||||||
aspectRatio={3.0 / 2.0}
|
aspectRatio={3.0 / 2.0}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export default function FieldsetWithStatus({
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
inputRef: inputRefProp,
|
inputRef: inputRefProp,
|
||||||
accessory,
|
accessory,
|
||||||
|
footer,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
}: {
|
}: {
|
||||||
@ -70,7 +71,8 @@ export default function FieldsetWithStatus({
|
|||||||
capitalize?: boolean
|
capitalize?: boolean
|
||||||
type?: FieldSetType
|
type?: FieldSetType
|
||||||
inputRef?: RefObject<HTMLInputElement | null>
|
inputRef?: RefObject<HTMLInputElement | null>
|
||||||
accessory?: React.ReactNode
|
accessory?: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
tabIndex?: number
|
tabIndex?: number
|
||||||
}) {
|
}) {
|
||||||
@ -240,6 +242,9 @@ export default function FieldsetWithStatus({
|
|||||||
{accessory}
|
{accessory}
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
{footer && <div className="mt-3">
|
||||||
|
{footer}
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
37
src/components/SmallDisclosure.tsx
Normal file
37
src/components/SmallDisclosure.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import clsx from 'clsx/lite';
|
||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { LuChevronRight } from 'react-icons/lu';
|
||||||
|
|
||||||
|
export default function SmallDisclosure({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 link',
|
||||||
|
'hover:opacity-100!',
|
||||||
|
)}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<span className={clsx(
|
||||||
|
'transition-transform duration-200',
|
||||||
|
isOpen && 'rotate-90',
|
||||||
|
)}>
|
||||||
|
<LuChevronRight size={16} />
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
{isOpen &&
|
||||||
|
<div className="pl-5.5">
|
||||||
|
{children}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo';
|
import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo';
|
||||||
import {
|
import { getOptimizedPhotoUrl } from '@/photo/storage';
|
||||||
getNextImageUrlForRequest,
|
import { NextImageSize } from '@/platforms/next-image';
|
||||||
NextImageSize,
|
|
||||||
} from '@/platforms/next-image';
|
|
||||||
|
|
||||||
export const FEED_PHOTO_REQUEST_LIMIT = 40;
|
export const FEED_PHOTO_REQUEST_LIMIT = 40;
|
||||||
|
|
||||||
@ -20,7 +18,7 @@ export const generateFeedMedia = (
|
|||||||
photo: Photo,
|
photo: Photo,
|
||||||
size: NextImageSize,
|
size: NextImageSize,
|
||||||
): FeedMedia => ({
|
): FeedMedia => ({
|
||||||
url: getNextImageUrlForRequest({ imageUrl: photo.url, size }),
|
url: getOptimizedPhotoUrl({ imageUrl: photo.url, size }),
|
||||||
width: size,
|
width: size,
|
||||||
height: Math.round(size / photo.aspectRatio),
|
height: Math.round(size / photo.aspectRatio),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export default function TagImageResponse({
|
|||||||
height,
|
height,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
icon: isTagFavs(tag)
|
icon: isTagFavs(tag)
|
||||||
? <span tw="text-amber-500 inline-flex ">
|
? <span tw="text-amber-500">
|
||||||
<IconFavs
|
<IconFavs
|
||||||
size={height * .066}
|
size={height * .066}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
/* eslint-disable jsx-a11y/alt-text */
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
|
|
||||||
import { Photo } from '@/photo';
|
import { Photo } from '@/photo';
|
||||||
import {
|
import { NextImageSize } from '@/platforms/next-image';
|
||||||
NextImageSize,
|
|
||||||
getNextImageUrlForRequest,
|
|
||||||
} from '@/platforms/next-image';
|
|
||||||
import { IS_PREVIEW } from '@/app/config';
|
import { IS_PREVIEW } from '@/app/config';
|
||||||
|
import {
|
||||||
|
doAllPhotosHaveOptimizedFiles,
|
||||||
|
getOptimizedPhotoUrl,
|
||||||
|
} from '@/photo/storage';
|
||||||
|
|
||||||
export default function ImagePhotoGrid({
|
export default async function ImagePhotoGrid({
|
||||||
photos,
|
photos,
|
||||||
width,
|
width,
|
||||||
widthArbitrary,
|
widthArbitrary,
|
||||||
@ -47,6 +48,8 @@ export default function ImagePhotoGrid({
|
|||||||
const cellHeight= height / rows -
|
const cellHeight= height / rows -
|
||||||
(rows - 1) * gap / rows;
|
(rows - 1) * gap / rows;
|
||||||
|
|
||||||
|
const doOptimizedFilesExist = await doAllPhotosHaveOptimizedFiles(photos);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -69,10 +72,11 @@ export default function ImagePhotoGrid({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img {...{
|
<img {...{
|
||||||
src: getNextImageUrlForRequest({
|
src: getOptimizedPhotoUrl({
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
size: nextImageWidth,
|
size: nextImageWidth,
|
||||||
addBypassSecret: IS_PREVIEW,
|
addBypassSecret: IS_PREVIEW,
|
||||||
|
compatibilityMode: !doOptimizedFilesExist,
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
...imageStyle,
|
...imageStyle,
|
||||||
|
|||||||
@ -15,9 +15,11 @@ import ExifCaptureButton from '@/admin/ExifCaptureButton';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Recipes } from '@/recipe';
|
import { Recipes } from '@/recipe';
|
||||||
import { Films } from '@/film';
|
import { Films } from '@/film';
|
||||||
|
import { StorageListResponse } from '@/platforms/storage';
|
||||||
|
|
||||||
export default function PhotoEditPageClient({
|
export default function PhotoEditPageClient({
|
||||||
photo,
|
photo,
|
||||||
|
photoStorageUrls,
|
||||||
uniqueTags,
|
uniqueTags,
|
||||||
uniqueRecipes,
|
uniqueRecipes,
|
||||||
uniqueFilms,
|
uniqueFilms,
|
||||||
@ -26,6 +28,7 @@ export default function PhotoEditPageClient({
|
|||||||
blurData,
|
blurData,
|
||||||
}: {
|
}: {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
|
photoStorageUrls?: StorageListResponse
|
||||||
uniqueTags: Tags
|
uniqueTags: Tags
|
||||||
uniqueRecipes: Recipes
|
uniqueRecipes: Recipes
|
||||||
uniqueFilms: Films
|
uniqueFilms: Films
|
||||||
@ -77,6 +80,7 @@ export default function PhotoEditPageClient({
|
|||||||
<PhotoForm
|
<PhotoForm
|
||||||
type="edit"
|
type="edit"
|
||||||
initialPhotoForm={photoForm}
|
initialPhotoForm={photoForm}
|
||||||
|
photoStorageUrls={photoStorageUrls}
|
||||||
updatedExifData={updatedExifData}
|
updatedExifData={updatedExifData}
|
||||||
updatedBlurData={blurData}
|
updatedBlurData={blurData}
|
||||||
uniqueTags={uniqueTags}
|
uniqueTags={uniqueTags}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { uploadPhotoFromClient } from '@/platforms/storage';
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/path';
|
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/path';
|
||||||
import ImageInput from '../components/ImageInput';
|
import ImageInput from '../components/ImageInput';
|
||||||
@ -10,6 +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';
|
||||||
|
|
||||||
export default function PhotoUploadWithStatus({
|
export default function PhotoUploadWithStatus({
|
||||||
inputRef,
|
inputRef,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deletePhoto,
|
|
||||||
insertPhoto,
|
insertPhoto,
|
||||||
deletePhotoTagGlobally,
|
deletePhotoTagGlobally,
|
||||||
updatePhoto,
|
updatePhoto,
|
||||||
@ -43,6 +42,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
blurImageFromUrl,
|
blurImageFromUrl,
|
||||||
convertFormDataToPhotoDbInsertAndLookupRecipeTitle,
|
convertFormDataToPhotoDbInsertAndLookupRecipeTitle,
|
||||||
|
deletePhotoAndFiles,
|
||||||
extractImageDataFromBlobPath,
|
extractImageDataFromBlobPath,
|
||||||
propagateRecipeTitleIfNecessary,
|
propagateRecipeTitleIfNecessary,
|
||||||
} from './server';
|
} from './server';
|
||||||
@ -58,7 +58,7 @@ 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';
|
import { convertUploadToPhoto } 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';
|
||||||
@ -66,6 +66,7 @@ import {
|
|||||||
getColorFieldsForImageUrl,
|
getColorFieldsForImageUrl,
|
||||||
getColorFieldsForPhotoDbInsert,
|
getColorFieldsForPhotoDbInsert,
|
||||||
} from '@/photo/color/server';
|
} from '@/photo/color/server';
|
||||||
|
import { shouldBackfillPhotoStorage } from './update/server';
|
||||||
|
|
||||||
// Private actions
|
// Private actions
|
||||||
|
|
||||||
@ -78,7 +79,7 @@ export const createPhotoAction = async (formData: FormData) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto({
|
const updatedUrl = await convertUploadToPhoto({
|
||||||
urlOrigin: photo.url,
|
uploadUrl: photo.url,
|
||||||
shouldStripGpsData,
|
shouldStripGpsData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -174,7 +175,7 @@ const addUpload = async ({
|
|||||||
onStreamUpdate?.('Transferring to photo storage');
|
onStreamUpdate?.('Transferring to photo storage');
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto({
|
const updatedUrl = await convertUploadToPhoto({
|
||||||
urlOrigin: url,
|
uploadUrl: url,
|
||||||
fileBytes,
|
fileBytes,
|
||||||
shouldStripGpsData,
|
shouldStripGpsData,
|
||||||
});
|
});
|
||||||
@ -277,14 +278,11 @@ export const updatePhotoAction = async (formData: FormData) =>
|
|||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const photo =
|
const photo =
|
||||||
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(formData);
|
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(formData);
|
||||||
|
|
||||||
let urlToDelete: string | undefined;
|
let urlToDelete: string | undefined;
|
||||||
if (photo.hidden && photo.url.includes(photo.id)) {
|
if (await shouldBackfillPhotoStorage(photo)) {
|
||||||
// Backfill:
|
|
||||||
// Anonymize storage url on update if necessary by
|
|
||||||
// re-running image upload transfer logic
|
|
||||||
const url = await convertUploadToPhoto({
|
const url = await convertUploadToPhoto({
|
||||||
urlOrigin: photo.url,
|
uploadUrl: photo.url,
|
||||||
shouldDeleteOrigin: false,
|
shouldDeleteOrigin: false,
|
||||||
});
|
});
|
||||||
if (url) {
|
if (url) {
|
||||||
@ -356,7 +354,7 @@ export const deletePhotosAction = async (photoIds: string[]) =>
|
|||||||
for (const photoId of photoIds) {
|
for (const photoId of photoIds) {
|
||||||
const photo = await getPhoto(photoId, true);
|
const photo = await getPhoto(photoId, true);
|
||||||
if (photo) {
|
if (photo) {
|
||||||
await deletePhoto(photoId).then(() => deleteFile(photo.url));
|
await deletePhotoAndFiles(photoId, photo.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
revalidateAllKeysAndPaths();
|
revalidateAllKeysAndPaths();
|
||||||
@ -368,7 +366,7 @@ export const deletePhotoAction = async (
|
|||||||
shouldRedirect?: boolean,
|
shouldRedirect?: boolean,
|
||||||
) =>
|
) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
await deletePhoto(photoId).then(() => deleteFile(photoUrl));
|
await deletePhotoAndFiles(photoId, photoUrl);
|
||||||
revalidateAllKeysAndPaths();
|
revalidateAllKeysAndPaths();
|
||||||
if (shouldRedirect) {
|
if (shouldRedirect) {
|
||||||
redirect(PATH_ROOT);
|
redirect(PATH_ROOT);
|
||||||
@ -511,11 +509,11 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
|
|||||||
|
|
||||||
let urlToDelete: string | undefined;
|
let urlToDelete: string | undefined;
|
||||||
if (formDataFromExif) {
|
if (formDataFromExif) {
|
||||||
if (photo.url.includes(photo.id) || shouldStripGpsData) {
|
if (await shouldBackfillPhotoStorage(photo) || shouldStripGpsData) {
|
||||||
// Anonymize storage url on update if necessary by
|
// Anonymize storage url on update if necessary by
|
||||||
// re-running image upload transfer logic
|
// re-running image upload transfer logic
|
||||||
const url = await convertUploadToPhoto({
|
const url = await convertUploadToPhoto({
|
||||||
urlOrigin: photo.url,
|
uploadUrl: photo.url,
|
||||||
fileBytes,
|
fileBytes,
|
||||||
shouldStripGpsData,
|
shouldStripGpsData,
|
||||||
shouldDeleteOrigin: false,
|
shouldDeleteOrigin: false,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { convertRgbToOklab, parseHex } from 'culori';
|
import { convertRgbToOklab, parseHex } from 'culori';
|
||||||
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
|
||||||
import {
|
import {
|
||||||
AI_CONTENT_GENERATION_ENABLED,
|
AI_CONTENT_GENERATION_ENABLED,
|
||||||
IS_PREVIEW,
|
IS_PREVIEW,
|
||||||
@ -11,6 +10,7 @@ import { extractColors } from 'extract-colors';
|
|||||||
import { getImageBase64FromUrl } from '../server';
|
import { getImageBase64FromUrl } from '../server';
|
||||||
import { generateOpenAiImageQuery } from '@/platforms/openai';
|
import { generateOpenAiImageQuery } from '@/platforms/openai';
|
||||||
import { calculateColorSort } from './sort';
|
import { calculateColorSort } from './sort';
|
||||||
|
import { getOptimizedPhotoUrlForManipulation } from '../storage';
|
||||||
|
|
||||||
const NULL_RGB = { r: 0, g: 0, b: 0 };
|
const NULL_RGB = { r: 0, g: 0, b: 0 };
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ export const convertHexToOklch = (hex: string): Oklch => {
|
|||||||
|
|
||||||
// Convert image url to byte array
|
// Convert image url to byte array
|
||||||
const getImageDataFromUrl = async (_url: string) => {
|
const getImageDataFromUrl = async (_url: string) => {
|
||||||
const url = getNextImageUrlForManipulation(_url, IS_PREVIEW);
|
const url = getOptimizedPhotoUrlForManipulation(_url, IS_PREVIEW);
|
||||||
const imageBuffer = await fetch(decodeURIComponent(url))
|
const imageBuffer = await fetch(decodeURIComponent(url))
|
||||||
.then(res => res.arrayBuffer());
|
.then(res => res.arrayBuffer());
|
||||||
const image = sharp(imageBuffer);
|
const image = sharp(imageBuffer);
|
||||||
@ -121,7 +121,7 @@ export const getColorFromAI = async (
|
|||||||
_url: string,
|
_url: string,
|
||||||
useBatch?: boolean,
|
useBatch?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const url = getNextImageUrlForManipulation(_url, IS_PREVIEW);
|
const url = getOptimizedPhotoUrlForManipulation(_url, IS_PREVIEW);
|
||||||
const image = await getImageBase64FromUrl(url);
|
const image = await getImageBase64FromUrl(url);
|
||||||
const hexColor = await generateOpenAiImageQuery(image, `
|
const hexColor = await generateOpenAiImageQuery(image, `
|
||||||
Does this image have a primary subject color?
|
Does this image have a primary subject color?
|
||||||
|
|||||||
@ -36,7 +36,6 @@ import Spinner from '@/components/Spinner';
|
|||||||
import usePreventNavigation from '@/utility/usePreventNavigation';
|
import usePreventNavigation from '@/utility/usePreventNavigation';
|
||||||
import { useAppState } from '@/app/AppState';
|
import { useAppState } from '@/app/AppState';
|
||||||
import UpdateBlurDataButton from '../UpdateBlurDataButton';
|
import UpdateBlurDataButton from '../UpdateBlurDataButton';
|
||||||
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
|
|
||||||
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
|
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
|
||||||
import ErrorNote from '@/components/ErrorNote';
|
import ErrorNote from '@/components/ErrorNote';
|
||||||
import { convertRecipesForForm, Recipes } from '@/recipe';
|
import { convertRecipesForForm, Recipes } from '@/recipe';
|
||||||
@ -56,12 +55,20 @@ import { capitalize } from '@/utility/string';
|
|||||||
import AnchorSections from '@/components/AnchorSections';
|
import AnchorSections from '@/components/AnchorSections';
|
||||||
import useIsVisible from '@/utility/useIsVisible';
|
import useIsVisible from '@/utility/useIsVisible';
|
||||||
import useHash from '@/utility/useHash';
|
import useHash from '@/utility/useHash';
|
||||||
|
import { getOptimizedPhotoUrlForManipulation } from '../storage';
|
||||||
|
import {
|
||||||
|
getFileNamePartsFromStorageUrl,
|
||||||
|
StorageListResponse,
|
||||||
|
} from '@/platforms/storage';
|
||||||
|
import SmallDisclosure from '@/components/SmallDisclosure';
|
||||||
|
import { TbPhoto } from 'react-icons/tb';
|
||||||
|
|
||||||
const THUMBNAIL_SIZE = 300;
|
const THUMBNAIL_SIZE = 300;
|
||||||
|
|
||||||
export default function PhotoForm({
|
export default function PhotoForm({
|
||||||
type = 'create',
|
type = 'create',
|
||||||
initialPhotoForm,
|
initialPhotoForm,
|
||||||
|
photoStorageUrls,
|
||||||
updatedExifData,
|
updatedExifData,
|
||||||
updatedBlurData,
|
updatedBlurData,
|
||||||
uniqueTags,
|
uniqueTags,
|
||||||
@ -75,6 +82,7 @@ export default function PhotoForm({
|
|||||||
}: {
|
}: {
|
||||||
type?: 'create' | 'edit'
|
type?: 'create' | 'edit'
|
||||||
initialPhotoForm: Partial<PhotoFormData>
|
initialPhotoForm: Partial<PhotoFormData>
|
||||||
|
photoStorageUrls?: StorageListResponse
|
||||||
updatedExifData?: Partial<PhotoFormData>
|
updatedExifData?: Partial<PhotoFormData>
|
||||||
updatedBlurData?: string
|
updatedBlurData?: string
|
||||||
uniqueTags?: Tags
|
uniqueTags?: Tags
|
||||||
@ -245,7 +253,7 @@ export default function PhotoForm({
|
|||||||
case 'blurData':
|
case 'blurData':
|
||||||
return shouldDebugImageFallbacks && type === 'edit' && formData.url
|
return shouldDebugImageFallbacks && type === 'edit' && formData.url
|
||||||
? <UpdateBlurDataButton
|
? <UpdateBlurDataButton
|
||||||
photoUrl={getNextImageUrlForManipulation(
|
photoUrl={getOptimizedPhotoUrlForManipulation(
|
||||||
formData.url,
|
formData.url,
|
||||||
IS_PREVIEW,
|
IS_PREVIEW,
|
||||||
)}
|
)}
|
||||||
@ -257,6 +265,34 @@ export default function PhotoForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const footerForField = (key: keyof PhotoFormData) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'url':
|
||||||
|
return photoStorageUrls && photoStorageUrls.length > 1
|
||||||
|
? <SmallDisclosure label="Optimized file set">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{photoStorageUrls.map(({ url, size }) => {
|
||||||
|
const { fileName } = getFileNamePartsFromStorageUrl(url);
|
||||||
|
return <div
|
||||||
|
key={url}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<TbPhoto className="translate-y-[1px] text-medium" />
|
||||||
|
<Link
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</Link>
|
||||||
|
<span className="text-dim">{size}</span>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SmallDisclosure>
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isFieldHidden = (
|
const isFieldHidden = (
|
||||||
key: FormFields,
|
key: FormFields,
|
||||||
hideIfEmpty?: boolean,
|
hideIfEmpty?: boolean,
|
||||||
@ -500,6 +536,7 @@ export default function PhotoForm({
|
|||||||
),
|
),
|
||||||
type,
|
type,
|
||||||
accessory: accessoryForField(key),
|
accessory: accessoryForField(key),
|
||||||
|
footer: footerForField(key),
|
||||||
};
|
};
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'film':
|
case 'film':
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getExtensionFromStorageUrl,
|
deleteFilesWithPrefix,
|
||||||
getIdFromStorageUrl,
|
getFileNamePartsFromStorageUrl,
|
||||||
} from '@/platforms/storage';
|
} from '@/platforms/storage';
|
||||||
import { convertFormDataToPhotoDbInsert } from '@/photo/form';
|
import { convertFormDataToPhotoDbInsert } from '@/photo/form';
|
||||||
import {
|
import {
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
getFujifilmRecipeFromMakerNote,
|
getFujifilmRecipeFromMakerNote,
|
||||||
} from '@/platforms/fujifilm/recipe';
|
} from '@/platforms/fujifilm/recipe';
|
||||||
import {
|
import {
|
||||||
|
deletePhoto,
|
||||||
getRecipeTitleForData,
|
getRecipeTitleForData,
|
||||||
updateAllMatchingRecipeTitles,
|
updateAllMatchingRecipeTitles,
|
||||||
} from './db/query';
|
} from './db/query';
|
||||||
@ -28,8 +29,9 @@ import { convertExifToFormData } from './form/server';
|
|||||||
import { getColorFieldsForPhotoForm } from './color/server';
|
import { getColorFieldsForPhotoForm } from './color/server';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
|
|
||||||
const IMAGE_WIDTH_RESIZE = 200;
|
|
||||||
const IMAGE_WIDTH_BLUR = 200;
|
const IMAGE_WIDTH_BLUR = 200;
|
||||||
|
const IMAGE_WIDTH_DEFAULT = 200;
|
||||||
|
const IMAGE_QUALITY_DEFAULT = 80;
|
||||||
|
|
||||||
export const extractImageDataFromBlobPath = async (
|
export const extractImageDataFromBlobPath = async (
|
||||||
blobPath: string,
|
blobPath: string,
|
||||||
@ -54,9 +56,10 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
|
|
||||||
const url = decodeURIComponent(blobPath);
|
const url = decodeURIComponent(blobPath);
|
||||||
|
|
||||||
const blobId = getIdFromStorageUrl(url);
|
const {
|
||||||
|
fileExtension: extension,
|
||||||
const extension = getExtensionFromStorageUrl(url);
|
fileId: blobId,
|
||||||
|
} = getFileNamePartsFromStorageUrl(url);
|
||||||
|
|
||||||
let exifData: ExifData | undefined;
|
let exifData: ExifData | undefined;
|
||||||
let exifrData: any | undefined;
|
let exifrData: any | undefined;
|
||||||
@ -146,13 +149,13 @@ const generateBase64 = async (
|
|||||||
) =>
|
) =>
|
||||||
(middleware ? middleware(sharp(image)) : sharp(image))
|
(middleware ? middleware(sharp(image)) : sharp(image))
|
||||||
.withMetadata()
|
.withMetadata()
|
||||||
.toFormat('jpeg', { quality: 90 })
|
.toFormat('jpeg', { quality: IMAGE_QUALITY_DEFAULT })
|
||||||
.toBuffer()
|
.toBuffer()
|
||||||
.then(data => `data:image/jpeg;base64,${data.toString('base64')}`);
|
.then(data => `data:image/jpeg;base64,${data.toString('base64')}`);
|
||||||
|
|
||||||
const resizeImage = async (
|
const resizeImage = async (
|
||||||
image: ArrayBuffer,
|
image: ArrayBuffer,
|
||||||
width = IMAGE_WIDTH_RESIZE,
|
width = IMAGE_WIDTH_DEFAULT,
|
||||||
) =>
|
) =>
|
||||||
generateBase64(image, sharp => sharp
|
generateBase64(image, sharp => sharp
|
||||||
.resize(width),
|
.resize(width),
|
||||||
@ -195,6 +198,16 @@ export const blurImageFromUrl = async (url: string) =>
|
|||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resizeImageToBytes = async (
|
||||||
|
image: ArrayBuffer,
|
||||||
|
width: number,
|
||||||
|
quality = IMAGE_QUALITY_DEFAULT,
|
||||||
|
) =>
|
||||||
|
sharp(image)
|
||||||
|
.resize(width)
|
||||||
|
.toFormat('jpeg', { quality })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
const GPS_NULL_STRING = '-';
|
const GPS_NULL_STRING = '-';
|
||||||
|
|
||||||
export const removeGpsData = async (image: ArrayBuffer) =>
|
export const removeGpsData = async (image: ArrayBuffer) =>
|
||||||
@ -260,3 +273,13 @@ export const propagateRecipeTitleIfNecessary = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deletePhotoAndFiles = async (
|
||||||
|
photoId: string,
|
||||||
|
photoUrl: string,
|
||||||
|
) =>
|
||||||
|
deletePhoto(photoId)
|
||||||
|
.then(() => {
|
||||||
|
const { fileNameBase } = getFileNamePartsFromStorageUrl(photoUrl);
|
||||||
|
return deleteFilesWithPrefix(fileNameBase);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
import {
|
|
||||||
copyFile,
|
|
||||||
deleteFile,
|
|
||||||
generateRandomFileNameForPhoto,
|
|
||||||
getExtensionFromStorageUrl,
|
|
||||||
moveFile,
|
|
||||||
putFile,
|
|
||||||
} from '@/platforms/storage';
|
|
||||||
import { removeGpsData } from './server';
|
|
||||||
|
|
||||||
export const convertUploadToPhoto = async ({
|
|
||||||
urlOrigin,
|
|
||||||
fileBytes,
|
|
||||||
shouldStripGpsData,
|
|
||||||
shouldDeleteOrigin = true,
|
|
||||||
} : {
|
|
||||||
urlOrigin: string
|
|
||||||
fileBytes?: ArrayBuffer
|
|
||||||
shouldStripGpsData?: boolean
|
|
||||||
shouldDeleteOrigin?: boolean
|
|
||||||
}) => {
|
|
||||||
const fileName = generateRandomFileNameForPhoto();
|
|
||||||
const fileExtension = getExtensionFromStorageUrl(urlOrigin);
|
|
||||||
const photoPath = `${fileName}.${fileExtension || 'jpg'}`;
|
|
||||||
if (shouldStripGpsData) {
|
|
||||||
const fileWithoutGps = await removeGpsData(
|
|
||||||
fileBytes ?? await fetch(urlOrigin, { cache: 'no-store' })
|
|
||||||
.then(res => res.arrayBuffer()),
|
|
||||||
);
|
|
||||||
return putFile(fileWithoutGps, photoPath).then(async url => {
|
|
||||||
if (url && shouldDeleteOrigin) { await deleteFile(urlOrigin); }
|
|
||||||
return url;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return shouldDeleteOrigin
|
|
||||||
? moveFile(urlOrigin, photoPath)
|
|
||||||
: copyFile(urlOrigin, photoPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
157
src/photo/storage/index.ts
Normal file
157
src/photo/storage/index.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
getNextImageUrlForRequest,
|
||||||
|
NextImageSize,
|
||||||
|
} from '@/platforms/next-image';
|
||||||
|
import {
|
||||||
|
generateFileNameWithId,
|
||||||
|
getFileNamePartsFromStorageUrl,
|
||||||
|
getStorageUrlsForPrefix,
|
||||||
|
uploadFileFromClient,
|
||||||
|
} from '@/platforms/storage';
|
||||||
|
import { Photo } from '..';
|
||||||
|
|
||||||
|
const PREFIX_PHOTO = 'photo';
|
||||||
|
const PREFIX_UPLOAD = 'upload';
|
||||||
|
|
||||||
|
const EXTENSION_DEFAULT = 'jpg';
|
||||||
|
const EXTENSION_OPTIMIZED = 'jpg';
|
||||||
|
|
||||||
|
// For the time being, make compatible with `next/image` sizes
|
||||||
|
const OPTIMIZED_FILE_SIZES = [{
|
||||||
|
suffix: 'sm',
|
||||||
|
size: 200,
|
||||||
|
quality: 90,
|
||||||
|
}, {
|
||||||
|
suffix: 'md',
|
||||||
|
size: 640,
|
||||||
|
quality: 90,
|
||||||
|
}, {
|
||||||
|
suffix: 'lg',
|
||||||
|
size: 1080,
|
||||||
|
quality: 80,
|
||||||
|
}] as const satisfies {
|
||||||
|
suffix: string
|
||||||
|
size: NextImageSize
|
||||||
|
quality: number
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type OptimizedSuffix = (typeof OPTIMIZED_FILE_SIZES)[number]['suffix'];
|
||||||
|
|
||||||
|
const OPTIMIZED_SUFFIX_DEFAULT: OptimizedSuffix = 'md';
|
||||||
|
|
||||||
|
const getOptimizedFileName = ({
|
||||||
|
fileNameBase,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
fileNameBase: string
|
||||||
|
suffix: OptimizedSuffix
|
||||||
|
}) =>
|
||||||
|
`${fileNameBase}-${suffix}.${EXTENSION_OPTIMIZED}`;
|
||||||
|
|
||||||
|
const getOptimizedUrl =({
|
||||||
|
urlBase,
|
||||||
|
fileNameBase,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
urlBase: string
|
||||||
|
fileNameBase: string
|
||||||
|
suffix: OptimizedSuffix
|
||||||
|
}) =>
|
||||||
|
`${urlBase}/${getOptimizedFileName({ fileNameBase, suffix })}`;
|
||||||
|
|
||||||
|
export const getOptimizedPhotoFileMeta = (fileNameBase: string) =>
|
||||||
|
OPTIMIZED_FILE_SIZES.map(({ suffix, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
fileName: getOptimizedFileName({ fileNameBase, suffix }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const getOptimizedUrlsFromPhotoUrl = (url: string) => {
|
||||||
|
const { urlBase, fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
||||||
|
return getOptimizedPhotoFileMeta(fileNameBase).map(({ fileName }) =>
|
||||||
|
`${urlBase}/${fileName}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isUploadPathnameValid = (pathname?: string) =>
|
||||||
|
pathname?.match(new RegExp(`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`, 'i'));
|
||||||
|
|
||||||
|
export const generateRandomFileNameForPhoto = () =>
|
||||||
|
generateFileNameWithId(PREFIX_PHOTO);
|
||||||
|
|
||||||
|
export const getStorageUploadUrls = () =>
|
||||||
|
getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`);
|
||||||
|
|
||||||
|
export const getStoragePhotoUrls = () =>
|
||||||
|
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
||||||
|
|
||||||
|
export const uploadPhotoFromClient = (
|
||||||
|
file: File | Blob,
|
||||||
|
extension = EXTENSION_DEFAULT,
|
||||||
|
) =>
|
||||||
|
uploadFileFromClient(file, PREFIX_UPLOAD, extension);
|
||||||
|
|
||||||
|
const getSuffixFromNextImageSize = (nextSize: NextImageSize) =>
|
||||||
|
OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix
|
||||||
|
?? OPTIMIZED_SUFFIX_DEFAULT;
|
||||||
|
|
||||||
|
export const getOptimizedPhotoUrl = (
|
||||||
|
args: Parameters<typeof getNextImageUrlForRequest>[0] & {
|
||||||
|
compatibilityMode?: boolean
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { compatibilityMode = true } = args;
|
||||||
|
const suffix = getSuffixFromNextImageSize(args.size);
|
||||||
|
const {
|
||||||
|
urlBase,
|
||||||
|
fileNameBase,
|
||||||
|
} = getFileNamePartsFromStorageUrl(args.imageUrl);
|
||||||
|
return compatibilityMode
|
||||||
|
? getNextImageUrlForRequest(args)
|
||||||
|
: getOptimizedUrl({ urlBase, fileNameBase, suffix });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate small, low-bandwidth images for quick manipulations such as
|
||||||
|
// generating blur data or image thumbnails for AI text generation
|
||||||
|
export const getOptimizedPhotoUrlForManipulation = (
|
||||||
|
imageUrl: string,
|
||||||
|
addBypassSecret: boolean,
|
||||||
|
compatibilityMode?: boolean,
|
||||||
|
) =>
|
||||||
|
getOptimizedPhotoUrl({
|
||||||
|
imageUrl,
|
||||||
|
size: 640,
|
||||||
|
addBypassSecret,
|
||||||
|
compatibilityMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTestOptimizedPhotoUrl = (url: string) => {
|
||||||
|
const { urlBase, fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
||||||
|
return getOptimizedUrl({
|
||||||
|
urlBase,
|
||||||
|
fileNameBase,
|
||||||
|
suffix: OPTIMIZED_SUFFIX_DEFAULT,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const doesPhotoUrlHaveOptimizedFiles = async (url: string) =>
|
||||||
|
fetch(getTestOptimizedPhotoUrl(url)).then(res => res.ok);
|
||||||
|
|
||||||
|
export const doAllPhotosHaveOptimizedFiles = async (photos: Photo[]) =>
|
||||||
|
Promise.all(photos.map(({ url }) => fetch(getTestOptimizedPhotoUrl(url))))
|
||||||
|
.then(urls => urls.every(url => url.ok))
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
export const getStorageUrlsForPhoto = async ({ url }: Photo) => {
|
||||||
|
const getSortScoreForUrl = (url: string) => {
|
||||||
|
const { fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
||||||
|
if (fileNameBase.endsWith('-sm')) { return 1; }
|
||||||
|
if (fileNameBase.endsWith('-md')) { return 2; }
|
||||||
|
if (fileNameBase.endsWith('-lg')) { return 3; }
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
||||||
|
|
||||||
|
return getStorageUrlsForPrefix(fileNameBase).then(urls =>
|
||||||
|
urls.sort((a, b) => getSortScoreForUrl(a.url) - getSortScoreForUrl(b.url)),
|
||||||
|
);
|
||||||
|
};
|
||||||
58
src/photo/storage/server.ts
Normal file
58
src/photo/storage/server.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
copyFile,
|
||||||
|
deleteFile,
|
||||||
|
getFileNamePartsFromStorageUrl,
|
||||||
|
moveFile,
|
||||||
|
putFile,
|
||||||
|
} from '@/platforms/storage';
|
||||||
|
import { removeGpsData, resizeImageToBytes } from '../server';
|
||||||
|
import {
|
||||||
|
generateRandomFileNameForPhoto,
|
||||||
|
getOptimizedPhotoFileMeta,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
export const storeOptimizedPhotos = async (
|
||||||
|
url: string,
|
||||||
|
fileBytes: ArrayBuffer,
|
||||||
|
) => {
|
||||||
|
const { fileNameBase } = getFileNamePartsFromStorageUrl(url);
|
||||||
|
const optimizedPhotoFileMeta = getOptimizedPhotoFileMeta(fileNameBase);
|
||||||
|
for (const { fileName, size, quality } of optimizedPhotoFileMeta) {
|
||||||
|
await putFile(await resizeImageToBytes(fileBytes, size, quality), fileName);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertUploadToPhoto = async ({
|
||||||
|
uploadUrl,
|
||||||
|
fileBytes: _fileBytes,
|
||||||
|
shouldStripGpsData,
|
||||||
|
shouldDeleteOrigin = true,
|
||||||
|
} : {
|
||||||
|
uploadUrl: string
|
||||||
|
fileBytes?: ArrayBuffer
|
||||||
|
shouldStripGpsData?: boolean
|
||||||
|
shouldDeleteOrigin?: boolean
|
||||||
|
}) => {
|
||||||
|
const fileNameBase = generateRandomFileNameForPhoto();
|
||||||
|
const { fileExtension } = getFileNamePartsFromStorageUrl(uploadUrl);
|
||||||
|
const fileName = `${fileNameBase}.${fileExtension}`;
|
||||||
|
const fileBytes = _fileBytes
|
||||||
|
? _fileBytes
|
||||||
|
: await fetch(uploadUrl).then(res => res.arrayBuffer());
|
||||||
|
let promise: Promise<string>;
|
||||||
|
if (shouldStripGpsData) {
|
||||||
|
const fileWithoutGps = await removeGpsData(fileBytes);
|
||||||
|
promise = putFile(fileWithoutGps, fileName)
|
||||||
|
.then(async url => {
|
||||||
|
if (url && shouldDeleteOrigin) { await deleteFile(uploadUrl); }
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
promise = shouldDeleteOrigin
|
||||||
|
? moveFile(uploadUrl, fileName)
|
||||||
|
: copyFile(uploadUrl, fileName);
|
||||||
|
}
|
||||||
|
// Store optimized photos after original photo is copied/moved
|
||||||
|
return promise.then(async url => storeOptimizedPhotos(url, fileBytes));
|
||||||
|
};
|
||||||
10
src/photo/update/server.ts
Normal file
10
src/photo/update/server.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Photo, PhotoDbInsert } from '..';
|
||||||
|
import { doesPhotoUrlHaveOptimizedFiles } from '../storage';
|
||||||
|
|
||||||
|
// Used to anonymize storage/create optimized files if necessary
|
||||||
|
// by re-running convertUploadToPhoto (image upload transfer logic)
|
||||||
|
export const shouldBackfillPhotoStorage = async (
|
||||||
|
photo: Photo | PhotoDbInsert,
|
||||||
|
) =>
|
||||||
|
photo.url.includes(photo.id) ||
|
||||||
|
!await doesPhotoUrlHaveOptimizedFiles(photo.url);
|
||||||
@ -39,15 +39,3 @@ export const getNextImageUrlForRequest = ({
|
|||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate small, low-bandwidth images for quick manipulations such as
|
|
||||||
// generating blur data or image thumbnails for AI text generation
|
|
||||||
export const getNextImageUrlForManipulation = (
|
|
||||||
imageUrl: string,
|
|
||||||
addBypassSecret: boolean,
|
|
||||||
) =>
|
|
||||||
getNextImageUrlForRequest({
|
|
||||||
imageUrl,
|
|
||||||
size: 640,
|
|
||||||
addBypassSecret,
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { Photo } from '@/photo';
|
import { Photo } from '@/photo';
|
||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { JSX } from 'react';
|
import { JSX } from 'react';
|
||||||
import { getNextImageUrlForRequest } from './next-image';
|
|
||||||
import { IS_PREVIEW } from '@/app/config';
|
import { IS_PREVIEW } from '@/app/config';
|
||||||
|
import { getOptimizedPhotoUrl } from '@/photo/storage';
|
||||||
|
|
||||||
const isNextImageReadyBasedOnPhotos = async (
|
const isNextImageReadyBasedOnPhotos = async (
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
): Promise<boolean> =>
|
): Promise<boolean> =>
|
||||||
photos.length > 0 &&
|
photos.length > 0 &&
|
||||||
fetch(getNextImageUrlForRequest({
|
fetch(getOptimizedPhotoUrl({
|
||||||
imageUrl: photos[0].url,
|
imageUrl: photos[0].url,
|
||||||
size: 640,
|
size: 640,
|
||||||
addBypassSecret: IS_PREVIEW,
|
addBypassSecret: IS_PREVIEW,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { StorageListResponse, generateStorageId } from '.';
|
import { StorageListResponse, generateStorageId } from '.';
|
||||||
import { formatBytesToMB } from '@/utility/number';
|
import { formatBytes } from '@/utility/number';
|
||||||
|
|
||||||
const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? '';
|
const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? '';
|
||||||
const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? '';
|
const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? '';
|
||||||
@ -75,7 +75,7 @@ export const awsS3List = async (
|
|||||||
url: urlForKey(Key),
|
url: urlForKey(Key),
|
||||||
fileName: Key ?? '',
|
fileName: Key ?? '',
|
||||||
uploadedAt: LastModified,
|
uploadedAt: LastModified,
|
||||||
size: Size ? formatBytesToMB(Size) : undefined,
|
size: Size ? formatBytes(Size) : undefined,
|
||||||
})) ?? []);
|
})) ?? []);
|
||||||
|
|
||||||
export const awsS3Delete = async (Key: string) => {
|
export const awsS3Delete = async (Key: string) => {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { unstable_noStore } from 'next/cache';
|
import { unstable_noStore } from 'next/cache';
|
||||||
import { getStoragePhotoUrls, getStorageUploadUrls } from '@/platforms/storage';
|
import {
|
||||||
|
getStoragePhotoUrls,
|
||||||
|
getStorageUploadUrls,
|
||||||
|
} from '@/photo/storage';
|
||||||
|
|
||||||
export const getStorageUploadUrlsNoStore: typeof getStorageUploadUrls =
|
export const getStorageUploadUrlsNoStore: typeof getStorageUploadUrls =
|
||||||
(...args) => {
|
(...args) => {
|
||||||
@ -11,4 +14,4 @@ export const getStoragePhotoUrlsNoStore: typeof getStoragePhotoUrls =
|
|||||||
(...args) => {
|
(...args) => {
|
||||||
unstable_noStore();
|
unstable_noStore();
|
||||||
return getStoragePhotoUrls(...args);
|
return getStoragePhotoUrls(...args);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { StorageListResponse, generateStorageId } from '.';
|
import { StorageListResponse, generateStorageId } from '.';
|
||||||
import { removeUrlProtocol } from '@/utility/url';
|
import { removeUrlProtocol } from '@/utility/url';
|
||||||
import { formatBytesToMB } from '@/utility/number';
|
import { formatBytes } from '@/utility/number';
|
||||||
|
|
||||||
const CLOUDFLARE_R2_BUCKET =
|
const CLOUDFLARE_R2_BUCKET =
|
||||||
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? '';
|
process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? '';
|
||||||
@ -95,7 +95,7 @@ export const cloudflareR2List = async (
|
|||||||
url: urlForKey(Key),
|
url: urlForKey(Key),
|
||||||
fileName: Key ?? '',
|
fileName: Key ?? '',
|
||||||
uploadedAt: LastModified,
|
uploadedAt: LastModified,
|
||||||
size: Size ? formatBytesToMB(Size) : undefined,
|
size: Size ? formatBytes(Size) : undefined,
|
||||||
})) ?? []);
|
})) ?? []);
|
||||||
|
|
||||||
export const cloudflareR2Delete = async (Key: string) => {
|
export const cloudflareR2Delete = async (Key: string) => {
|
||||||
|
|||||||
@ -40,8 +40,6 @@ import {
|
|||||||
} from './minio';
|
} from './minio';
|
||||||
import { PATH_API_PRESIGNED_URL } from '@/app/path';
|
import { PATH_API_PRESIGNED_URL } from '@/app/path';
|
||||||
|
|
||||||
export const generateStorageId = () => generateNanoid(16);
|
|
||||||
|
|
||||||
export type StorageListItem = {
|
export type StorageListItem = {
|
||||||
url: string
|
url: string
|
||||||
fileName: string
|
fileName: string
|
||||||
@ -57,6 +55,29 @@ export type StorageType =
|
|||||||
'cloudflare-r2' |
|
'cloudflare-r2' |
|
||||||
'minio';
|
'minio';
|
||||||
|
|
||||||
|
export const generateStorageId = () => generateNanoid(16);
|
||||||
|
|
||||||
|
export const generateFileNameWithId = (prefix: string) =>
|
||||||
|
`${prefix}-${generateStorageId()}`;
|
||||||
|
|
||||||
|
export const getFileNamePartsFromStorageUrl = (url: string) => {
|
||||||
|
const [
|
||||||
|
_,
|
||||||
|
urlBase = '',
|
||||||
|
fileName = '',
|
||||||
|
fileNameBase = '',
|
||||||
|
fileId = '',
|
||||||
|
fileExtension = '',
|
||||||
|
] = url.match(/^(.+)\/((-*[a-z0-9]+-*([a-z0-9-]+))\.([a-z]{1,4}))$/i) ?? [];
|
||||||
|
return {
|
||||||
|
urlBase,
|
||||||
|
fileName,
|
||||||
|
fileNameBase,
|
||||||
|
fileId,
|
||||||
|
fileExtension,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const labelForStorage = (type: StorageType): string => {
|
export const labelForStorage = (type: StorageType): string => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'vercel-blob': return 'Vercel Blob';
|
case 'vercel-blob': return 'Vercel Blob';
|
||||||
@ -87,56 +108,15 @@ export const storageTypeFromUrl = (url: string): StorageType => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREFIX_UPLOAD = 'upload';
|
|
||||||
const PREFIX_PHOTO = 'photo';
|
|
||||||
|
|
||||||
export const generateRandomFileNameForPhoto = () =>
|
|
||||||
`${PREFIX_PHOTO}-${generateStorageId()}`;
|
|
||||||
|
|
||||||
const REGEX_UPLOAD_PATH = new RegExp(
|
|
||||||
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
const REGEX_UPLOAD_ID = new RegExp(
|
|
||||||
`.${PREFIX_UPLOAD}-([a-z0-9]+)\.[a-z]{1,4}$`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getFilePathFromStorageUrl = (url: string) => {
|
|
||||||
switch (storageTypeFromUrl(url)) {
|
|
||||||
case 'vercel-blob':
|
|
||||||
return url.replace(`${VERCEL_BLOB_BASE_URL}/`, '');
|
|
||||||
case 'cloudflare-r2':
|
|
||||||
return url.replace(`${CLOUDFLARE_R2_BASE_URL_PUBLIC}/`, '');
|
|
||||||
case 'aws-s3':
|
|
||||||
return url.replace(`${AWS_S3_BASE_URL}/`, '');
|
|
||||||
case 'minio':
|
|
||||||
return url.replace(`${MINIO_BASE_URL}/`, '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getExtensionFromStorageUrl = (url: string) =>
|
|
||||||
url.match(/.([a-z]{1,4})$/i)?.[1];
|
|
||||||
|
|
||||||
export const getIdFromStorageUrl = (url: string) =>
|
|
||||||
url.match(REGEX_UPLOAD_ID)?.[1];
|
|
||||||
|
|
||||||
export const isUploadPathnameValid = (pathname?: string) =>
|
|
||||||
pathname?.match(REGEX_UPLOAD_PATH);
|
|
||||||
|
|
||||||
const getFileNameFromStorageUrl = (url: string) =>
|
|
||||||
(new URL(url).pathname.match(/\/(.+)$/)?.[1]) ?? '';
|
|
||||||
|
|
||||||
export const uploadFromClientViaPresignedUrl = async (
|
export const uploadFromClientViaPresignedUrl = async (
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
fileName: string,
|
fileNameBase: string,
|
||||||
extension: string,
|
extension: string,
|
||||||
addRandomSuffix?: boolean,
|
addRandomSuffix?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const key = addRandomSuffix
|
const key = addRandomSuffix
|
||||||
? `${fileName}-${generateStorageId()}.${extension}`
|
? `${fileNameBase}-${generateStorageId()}.${extension}`
|
||||||
: `${fileName}.${extension}`;
|
: `${fileNameBase}.${extension}`;
|
||||||
|
|
||||||
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`)
|
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`)
|
||||||
.then((response) => response.text());
|
.then((response) => response.text());
|
||||||
@ -145,16 +125,17 @@ export const uploadFromClientViaPresignedUrl = async (
|
|||||||
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`);
|
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadPhotoFromClient = async (
|
export const uploadFileFromClient = async (
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
extension = 'jpg',
|
fileNameBase: string,
|
||||||
|
extension: string,
|
||||||
) => (
|
) => (
|
||||||
CURRENT_STORAGE === 'cloudflare-r2' ||
|
CURRENT_STORAGE === 'cloudflare-r2' ||
|
||||||
CURRENT_STORAGE === 'aws-s3' ||
|
CURRENT_STORAGE === 'aws-s3' ||
|
||||||
CURRENT_STORAGE === 'minio'
|
CURRENT_STORAGE === 'minio'
|
||||||
)
|
)
|
||||||
? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true)
|
? uploadFromClientViaPresignedUrl(file, fileNameBase, extension, true)
|
||||||
: vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`);
|
: vercelBlobUploadFromClient(file, `${fileNameBase}.${extension}`);
|
||||||
|
|
||||||
export const putFile = (
|
export const putFile = (
|
||||||
file: Buffer,
|
file: Buffer,
|
||||||
@ -176,6 +157,7 @@ export const copyFile = (
|
|||||||
originUrl: string,
|
originUrl: string,
|
||||||
destinationFileName: string,
|
destinationFileName: string,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
|
const { fileName } = getFileNamePartsFromStorageUrl(originUrl);
|
||||||
switch (storageTypeFromUrl(originUrl)) {
|
switch (storageTypeFromUrl(originUrl)) {
|
||||||
case 'vercel-blob':
|
case 'vercel-blob':
|
||||||
return vercelBlobCopy(
|
return vercelBlobCopy(
|
||||||
@ -185,7 +167,7 @@ export const copyFile = (
|
|||||||
);
|
);
|
||||||
case 'cloudflare-r2':
|
case 'cloudflare-r2':
|
||||||
return cloudflareR2Copy(
|
return cloudflareR2Copy(
|
||||||
getFileNameFromStorageUrl(originUrl),
|
fileName,
|
||||||
destinationFileName,
|
destinationFileName,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@ -197,7 +179,7 @@ export const copyFile = (
|
|||||||
);
|
);
|
||||||
case 'minio':
|
case 'minio':
|
||||||
return minioCopy(
|
return minioCopy(
|
||||||
getFileNameFromStorageUrl(originUrl),
|
fileName,
|
||||||
destinationFileName,
|
destinationFileName,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@ -205,18 +187,24 @@ export const copyFile = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteFile = (url: string) => {
|
export const deleteFile = (url: string) => {
|
||||||
|
const { fileName } = getFileNamePartsFromStorageUrl(url);
|
||||||
switch (storageTypeFromUrl(url)) {
|
switch (storageTypeFromUrl(url)) {
|
||||||
case 'vercel-blob':
|
case 'vercel-blob':
|
||||||
return vercelBlobDelete(url);
|
return vercelBlobDelete(url);
|
||||||
case 'cloudflare-r2':
|
case 'cloudflare-r2':
|
||||||
return cloudflareR2Delete(getFilePathFromStorageUrl(url));
|
return cloudflareR2Delete(fileName);
|
||||||
case 'aws-s3':
|
case 'aws-s3':
|
||||||
return awsS3Delete(getFilePathFromStorageUrl(url));
|
return awsS3Delete(fileName);
|
||||||
case 'minio':
|
case 'minio':
|
||||||
return minioDelete(getFilePathFromStorageUrl(url));
|
return minioDelete(fileName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deleteFilesWithPrefix = async (prefix: string) => {
|
||||||
|
const urls = await getStorageUrlsForPrefix(prefix);
|
||||||
|
return Promise.all(urls.map(({ url }) => deleteFile(url)));
|
||||||
|
};
|
||||||
|
|
||||||
export const moveFile = async (
|
export const moveFile = async (
|
||||||
originUrl: string,
|
originUrl: string,
|
||||||
destinationFileName: string,
|
destinationFileName: string,
|
||||||
@ -227,7 +215,7 @@ export const moveFile = async (
|
|||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStorageUrlsForPrefix = async (prefix = '') => {
|
export const getStorageUrlsForPrefix = async (prefix = '') => {
|
||||||
const urls: StorageListResponse = [];
|
const urls: StorageListResponse = [];
|
||||||
|
|
||||||
if (HAS_VERCEL_BLOB_STORAGE) {
|
if (HAS_VERCEL_BLOB_STORAGE) {
|
||||||
@ -255,11 +243,5 @@ const getStorageUrlsForPrefix = async (prefix = '') => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStorageUploadUrls = () =>
|
|
||||||
getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`);
|
|
||||||
|
|
||||||
export const getStoragePhotoUrls = () =>
|
|
||||||
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
|
|
||||||
|
|
||||||
export const testStorageConnection = () =>
|
export const testStorageConnection = () =>
|
||||||
getStorageUrlsForPrefix();
|
getStorageUrlsForPrefix();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { StorageListResponse, generateStorageId } from '.';
|
import { StorageListResponse, generateStorageId } from '.';
|
||||||
import { formatBytesToMB } from '@/utility/number';
|
import { formatBytes } from '@/utility/number';
|
||||||
|
|
||||||
const MINIO_BUCKET = process.env.NEXT_PUBLIC_MINIO_BUCKET ?? '';
|
const MINIO_BUCKET = process.env.NEXT_PUBLIC_MINIO_BUCKET ?? '';
|
||||||
const MINIO_DOMAIN = process.env.NEXT_PUBLIC_MINIO_DOMAIN ?? '';
|
const MINIO_DOMAIN = process.env.NEXT_PUBLIC_MINIO_DOMAIN ?? '';
|
||||||
@ -65,7 +65,8 @@ export const minioCopy = async (
|
|||||||
: fileNameDestination;
|
: fileNameDestination;
|
||||||
return minioClient().send(new CopyObjectCommand({
|
return minioClient().send(new CopyObjectCommand({
|
||||||
Bucket: MINIO_BUCKET,
|
Bucket: MINIO_BUCKET,
|
||||||
CopySource: fileNameSource,
|
// Bucket behavior seems to differ from R2 + S3
|
||||||
|
CopySource: `${MINIO_BUCKET}/${fileNameSource}`,
|
||||||
Key,
|
Key,
|
||||||
}))
|
}))
|
||||||
.then(() => urlForKey(Key));
|
.then(() => urlForKey(Key));
|
||||||
@ -82,7 +83,7 @@ export const minioList = async (
|
|||||||
url: urlForKey(Key),
|
url: urlForKey(Key),
|
||||||
fileName: Key ?? '',
|
fileName: Key ?? '',
|
||||||
uploadedAt: LastModified,
|
uploadedAt: LastModified,
|
||||||
size: Size ? formatBytesToMB(Size) : undefined,
|
size: Size ? formatBytes(Size) : undefined,
|
||||||
})) ?? []);
|
})) ?? []);
|
||||||
|
|
||||||
export const minioDelete = async (Key: string): Promise<void> => {
|
export const minioDelete = async (Key: string): Promise<void> => {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { PATH_API_VERCEL_BLOB_UPLOAD } from '@/app/path';
|
import { PATH_API_VERCEL_BLOB_UPLOAD } from '@/app/path';
|
||||||
import { copy, del, list, put } from '@vercel/blob';
|
import { copy, del, list, put } from '@vercel/blob';
|
||||||
import { upload } from '@vercel/blob/client';
|
import { upload } from '@vercel/blob/client';
|
||||||
import { getFilePathFromStorageUrl, StorageListResponse } from '.';
|
import { getFileNamePartsFromStorageUrl, StorageListResponse } from '.';
|
||||||
import { formatBytesToMB } from '@/utility/number';
|
import { formatBytes } from '@/utility/number';
|
||||||
|
|
||||||
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
||||||
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
/^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i,
|
||||||
@ -55,7 +55,7 @@ export const vercelBlobList = (
|
|||||||
): Promise<StorageListResponse> => list({ prefix })
|
): Promise<StorageListResponse> => list({ prefix })
|
||||||
.then(({ blobs }) => blobs.map(({ url, uploadedAt, size }) => ({
|
.then(({ blobs }) => blobs.map(({ url, uploadedAt, size }) => ({
|
||||||
url,
|
url,
|
||||||
fileName: getFilePathFromStorageUrl(url),
|
fileName: getFileNamePartsFromStorageUrl(url).fileName,
|
||||||
uploadedAt,
|
uploadedAt,
|
||||||
size: formatBytesToMB(size),
|
size: formatBytes(size),
|
||||||
})));
|
})));
|
||||||
|
|||||||
@ -88,12 +88,13 @@ export const formatNumberToFraction = (number: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatBytesToMB = (
|
export const formatBytes = (
|
||||||
bytes: number,
|
bytes: number,
|
||||||
byteSize = 1000,
|
byteSize = 1000,
|
||||||
precision = 1,
|
|
||||||
) =>
|
) =>
|
||||||
`${(bytes / byteSize / byteSize).toFixed(precision)}MB`;
|
bytes < byteSize * byteSize
|
||||||
|
? `${Math.round(bytes / byteSize)}KB`
|
||||||
|
: `${(bytes / byteSize / byteSize).toFixed(1)}MB`;
|
||||||
|
|
||||||
export const convertNumberToRomanNumeral = (number: number) => {
|
export const convertNumberToRomanNumeral = (number: number) => {
|
||||||
const romanNumerals = [
|
const romanNumerals = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user