Merge pull request #99 from sambecker/multiple-uploads
Add multiple uploads at once
This commit is contained in:
commit
ed5e041c77
@ -240,3 +240,6 @@ FAQ
|
|||||||
|
|
||||||
#### Why does my image placeholder blur look different from photo to photo?
|
#### Why does my image placeholder blur look different from photo to photo?
|
||||||
> Earlier versions of this template generated blur data on the client, which varied visually from browser to browser. Data is now generated consistently on the server. If you wish to update blur data for a particular photo, edit the photo in question, make no changes, and choose "Update."
|
> Earlier versions of this template generated blur data on the client, which varied visually from browser to browser. Data is now generated consistently on the server. If you wish to update blur data for a particular photo, edit the photo in question, make no changes, and choose "Update."
|
||||||
|
|
||||||
|
#### Why are large, multi-photo uploads not finishing?
|
||||||
|
> The default timeout for processing multiple uploads is 60 seconds (the limit for Hobby accounts). This can be extended to 5 minutes on Pro accounts by setting `maxDuration = 300` in `src/app/admin/uploads/page.tsx`.
|
||||||
|
|||||||
123
src/admin/AdminAddAllUploads.tsx
Normal file
123
src/admin/AdminAddAllUploads.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ErrorNote from '@/components/ErrorNote';
|
||||||
|
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||||
|
import InfoBlock from '@/components/InfoBlock';
|
||||||
|
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||||
|
import { addAllUploadsAction } from '@/photo/actions';
|
||||||
|
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||||
|
import {
|
||||||
|
TagsWithMeta,
|
||||||
|
convertTagsForForm,
|
||||||
|
getValidationMessageForTags,
|
||||||
|
} from '@/tag';
|
||||||
|
import {
|
||||||
|
generateLocalNaivePostgresString,
|
||||||
|
generateLocalPostgresString,
|
||||||
|
} from '@/utility/date';
|
||||||
|
import { clsx } from 'clsx/lite';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { BiImageAdd } from 'react-icons/bi';
|
||||||
|
|
||||||
|
export default function AdminAddAllUploads({
|
||||||
|
storageUrlCount,
|
||||||
|
uniqueTags,
|
||||||
|
}: {
|
||||||
|
storageUrlCount: number
|
||||||
|
uniqueTags?: TagsWithMeta
|
||||||
|
}) {
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showTags, setShowTags] = useState(false);
|
||||||
|
const [tags, setTags] = useState('');
|
||||||
|
const [actionErrorMessage, setActionErrorMessage] = useState('');
|
||||||
|
const [tagErrorMessage, setTagErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{actionErrorMessage &&
|
||||||
|
<ErrorNote>{actionErrorMessage}</ErrorNote>}
|
||||||
|
<InfoBlock padding="tight">
|
||||||
|
<div className="w-full space-y-4 py-1">
|
||||||
|
<div className="flex">
|
||||||
|
<div className={clsx(
|
||||||
|
'flex-grow',
|
||||||
|
tagErrorMessage ? 'text-error' : 'text-main',
|
||||||
|
)}>
|
||||||
|
{showTags
|
||||||
|
? tagErrorMessage || 'Add tags to all uploads'
|
||||||
|
: `Found ${storageUrlCount} uploads`}
|
||||||
|
</div>
|
||||||
|
<FieldSetWithStatus
|
||||||
|
id="show-tags"
|
||||||
|
label="Apply tags"
|
||||||
|
type="checkbox"
|
||||||
|
value={showTags ? 'true' : 'false'}
|
||||||
|
onChange={value => {
|
||||||
|
setShowTags(value === 'true');
|
||||||
|
if (value === 'true') {
|
||||||
|
setTimeout(() =>
|
||||||
|
divRef.current?.querySelectorAll('input')[0]?.focus()
|
||||||
|
, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
readOnly={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={divRef}
|
||||||
|
className={showTags ? undefined : 'hidden'}
|
||||||
|
>
|
||||||
|
<FieldSetWithStatus
|
||||||
|
id="tags"
|
||||||
|
label="Optional Tags"
|
||||||
|
tagOptions={convertTagsForForm(uniqueTags)}
|
||||||
|
value={tags}
|
||||||
|
onChange={tags => {
|
||||||
|
setTags(tags);
|
||||||
|
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
|
||||||
|
}}
|
||||||
|
readOnly={isLoading}
|
||||||
|
error={tagErrorMessage}
|
||||||
|
required={false}
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LoaderButton
|
||||||
|
className="primary w-full justify-center"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={Boolean(tagErrorMessage)}
|
||||||
|
icon={<BiImageAdd size={18} className="translate-x-[1px]" />}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(
|
||||||
|
`Are you sure you want to add all ${storageUrlCount} uploads?`
|
||||||
|
)) {
|
||||||
|
setIsLoading(true);
|
||||||
|
addAllUploadsAction({
|
||||||
|
tags: showTags ? tags : undefined,
|
||||||
|
takenAtLocal: generateLocalPostgresString(),
|
||||||
|
takenAtNaiveLocal: generateLocalNaivePostgresString(),
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
router.push(PATH_ADMIN_PHOTOS))
|
||||||
|
.catch(e => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setActionErrorMessage(e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
hideTextOnMobile={false}
|
||||||
|
>
|
||||||
|
Add all {storageUrlCount} uploads
|
||||||
|
</LoaderButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfoBlock>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,24 @@
|
|||||||
import AdminUploadsTable from '@/admin/AdminUploadsTable';
|
import AdminUploadsTable from '@/admin/AdminUploadsTable';
|
||||||
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||||
import SiteGrid from '@/components/SiteGrid';
|
import SiteGrid from '@/components/SiteGrid';
|
||||||
|
import AdminAddAllUploads from '@/admin/AdminAddAllUploads';
|
||||||
|
import { getUniqueTagsCached } from '@/photo/cache';
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
export default async function AdminUploadsPage() {
|
export default async function AdminUploadsPage() {
|
||||||
const storageUrls = await getStorageUploadUrlsNoStore();
|
const storageUrls = await getStorageUploadUrlsNoStore();
|
||||||
|
const uniqueTags = await getUniqueTagsCached();
|
||||||
return (
|
return (
|
||||||
<SiteGrid
|
<SiteGrid
|
||||||
contentMain={<AdminUploadsTable urls={storageUrls} />}
|
contentMain={<div className="space-y-4">
|
||||||
|
{storageUrls.length > 1 &&
|
||||||
|
<AdminAddAllUploads
|
||||||
|
storageUrlCount={storageUrls.length}
|
||||||
|
uniqueTags={uniqueTags}
|
||||||
|
/>}
|
||||||
|
<AdminUploadsTable urls={storageUrls} />
|
||||||
|
</div>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,18 +3,21 @@ import { ReactNode } from 'react';
|
|||||||
import { BiErrorAlt } from 'react-icons/bi';
|
import { BiErrorAlt } from 'react-icons/bi';
|
||||||
|
|
||||||
export default function ErrorNote({
|
export default function ErrorNote({
|
||||||
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
className?: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'flex items-center gap-3',
|
'flex w-full items-center gap-3',
|
||||||
'px-3 py-2 border',
|
'px-3 py-2 border',
|
||||||
'text-red-600 dark:text-red-500/90',
|
'text-red-600 dark:text-red-500/90',
|
||||||
'bg-red-50/50 dark:bg-red-950/50',
|
'bg-red-50/50 dark:bg-red-950/50',
|
||||||
'border-red-100 dark:border-red-950',
|
'border-red-100 dark:border-red-950',
|
||||||
'rounded-md',
|
'rounded-md',
|
||||||
|
className,
|
||||||
)}>
|
)}>
|
||||||
<BiErrorAlt
|
<BiErrorAlt
|
||||||
size={18}
|
size={18}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export default function FieldSetWithStatus({
|
|||||||
type = 'text',
|
type = 'text',
|
||||||
inputRef,
|
inputRef,
|
||||||
accessory,
|
accessory,
|
||||||
|
hideLabel,
|
||||||
}: {
|
}: {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@ -45,39 +46,47 @@ export default function FieldSetWithStatus({
|
|||||||
type?: FieldSetType
|
type?: FieldSetType
|
||||||
inputRef?: LegacyRef<HTMLInputElement>
|
inputRef?: LegacyRef<HTMLInputElement>
|
||||||
accessory?: React.ReactNode
|
accessory?: React.ReactNode
|
||||||
|
hideLabel?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className={clsx(
|
||||||
<label
|
'space-y-1',
|
||||||
className="flex gap-2 items-center select-none"
|
type === 'checkbox' && 'flex items-center gap-2',
|
||||||
htmlFor={id}
|
)}>
|
||||||
>
|
{!hideLabel &&
|
||||||
{label}
|
<label
|
||||||
{note && !error &&
|
className={clsx(
|
||||||
<span className="text-gray-400 dark:text-gray-600">
|
'flex gap-2 items-center select-none',
|
||||||
({note})
|
type === 'checkbox' && 'order-2 pt-[3px]',
|
||||||
</span>}
|
)}
|
||||||
{isModified && !error &&
|
htmlFor={id}
|
||||||
<span className={clsx(
|
>
|
||||||
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
|
{label}
|
||||||
)}>
|
{note && !error &&
|
||||||
*
|
<span className="text-gray-400 dark:text-gray-600">
|
||||||
</span>}
|
({note})
|
||||||
{error &&
|
</span>}
|
||||||
<span className="text-error">
|
{isModified && !error &&
|
||||||
{error}
|
<span className={clsx(
|
||||||
</span>}
|
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
|
||||||
{required &&
|
)}>
|
||||||
<span className="text-gray-400 dark:text-gray-600">
|
*
|
||||||
Required
|
</span>}
|
||||||
</span>}
|
{error &&
|
||||||
{loading &&
|
<span className="text-error">
|
||||||
<span className="translate-y-[1.5px]">
|
{error}
|
||||||
<Spinner />
|
</span>}
|
||||||
</span>}
|
{required &&
|
||||||
</label>
|
<span className="text-gray-400 dark:text-gray-600">
|
||||||
|
Required
|
||||||
|
</span>}
|
||||||
|
{loading &&
|
||||||
|
<span className="translate-y-[1.5px]">
|
||||||
|
<Spinner />
|
||||||
|
</span>}
|
||||||
|
</label>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectOptions
|
{selectOptions
|
||||||
? <select
|
? <select
|
||||||
@ -111,6 +120,7 @@ export default function FieldSetWithStatus({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={clsx(Boolean(error) && 'error')}
|
className={clsx(Boolean(error) && 'error')}
|
||||||
readOnly={readOnly || pending || loading}
|
readOnly={readOnly || pending || loading}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
: type === 'textarea'
|
: type === 'textarea'
|
||||||
? <textarea
|
? <textarea
|
||||||
@ -139,12 +149,18 @@ export default function FieldSetWithStatus({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||||
readOnly={readOnly || pending || loading}
|
readOnly={readOnly || pending || loading}
|
||||||
|
disabled={type === 'checkbox' && (
|
||||||
|
readOnly || pending || loading
|
||||||
|
)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
(
|
(
|
||||||
type === 'text' ||
|
type === 'text' ||
|
||||||
type === 'email' ||
|
type === 'email' ||
|
||||||
type === 'password'
|
type === 'password'
|
||||||
) && 'w-full',
|
) && 'w-full',
|
||||||
|
type === 'checkbox' && (
|
||||||
|
readOnly || pending || loading
|
||||||
|
) && 'opacity-50 cursor-not-allowed',
|
||||||
Boolean(error) && 'error',
|
Boolean(error) && 'error',
|
||||||
)}
|
)}
|
||||||
/>}
|
/>}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export default function TagInput({
|
|||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
placeholder,
|
||||||
}: {
|
}: {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
@ -25,6 +26,7 @@ export default function TagInput({
|
|||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
placeholder?: string
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLInputElement>(null);
|
const containerRef = useRef<HTMLInputElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -239,6 +241,7 @@ export default function TagInput({
|
|||||||
role="button"
|
role="button"
|
||||||
aria-label={`Remove tag "${option}"`}
|
aria-label={`Remove tag "${option}"`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
'text-main',
|
||||||
'cursor-pointer select-none',
|
'cursor-pointer select-none',
|
||||||
'whitespace-nowrap',
|
'whitespace-nowrap',
|
||||||
'px-1.5 py-0.5',
|
'px-1.5 py-0.5',
|
||||||
@ -257,6 +260,7 @@ export default function TagInput({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'grow !min-w-0 !p-0 -my-2 text-xl',
|
'grow !min-w-0 !p-0 -my-2 text-xl',
|
||||||
'!border-none !ring-transparent',
|
'!border-none !ring-transparent',
|
||||||
|
'placeholder:text-dim',
|
||||||
)}
|
)}
|
||||||
size={10}
|
size={10}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
@ -264,6 +268,7 @@ export default function TagInput({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
|
||||||
onFocus={() => setSelectedOptionIndex(undefined)}
|
onFocus={() => setSelectedOptionIndex(undefined)}
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-expanded={shouldShowMenu}
|
aria-expanded={shouldShowMenu}
|
||||||
|
|||||||
@ -41,13 +41,19 @@ import { convertPhotoToPhotoDbInsert } from '.';
|
|||||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
import { runAuthenticatedAdminServerAction } from '@/auth';
|
||||||
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
|
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
|
||||||
import { streamOpenAiImageQuery } from '@/services/openai';
|
import { streamOpenAiImageQuery } from '@/services/openai';
|
||||||
import { BLUR_ENABLED } from '@/site/config';
|
import {
|
||||||
|
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||||
|
AI_TEXT_GENERATION_ENABLED,
|
||||||
|
BLUR_ENABLED,
|
||||||
|
} from '@/site/config';
|
||||||
|
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||||
|
import { generateAiImageQueries } from './ai/server';
|
||||||
|
|
||||||
// Private actions
|
// Private actions
|
||||||
|
|
||||||
export const createPhotoAction = async (formData: FormData) =>
|
export const createPhotoAction = async (formData: FormData) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const photo = convertFormDataToPhotoDbInsert(formData, true);
|
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||||
|
|
||||||
const updatedUrl = await convertUploadToPhoto(photo.url);
|
const updatedUrl = await convertUploadToPhoto(photo.url);
|
||||||
|
|
||||||
@ -59,6 +65,63 @@ export const createPhotoAction = async (formData: FormData) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const addAllUploadsAction = async ({
|
||||||
|
tags,
|
||||||
|
takenAtLocal,
|
||||||
|
takenAtNaiveLocal,
|
||||||
|
}: {
|
||||||
|
tags?: string
|
||||||
|
takenAtLocal: string
|
||||||
|
takenAtNaiveLocal: string
|
||||||
|
}) =>
|
||||||
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
|
const uploadUrls = await getStorageUploadUrlsNoStore();
|
||||||
|
|
||||||
|
for (const { url } of uploadUrls) {
|
||||||
|
const {
|
||||||
|
photoFormExif,
|
||||||
|
imageResizedBase64,
|
||||||
|
} = await extractImageDataFromBlobPath(url, {
|
||||||
|
includeInitialPhotoFields: true,
|
||||||
|
generateBlurData: BLUR_ENABLED,
|
||||||
|
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (photoFormExif) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
tags: aiTags,
|
||||||
|
semanticDescription,
|
||||||
|
} = await generateAiImageQueries(
|
||||||
|
imageResizedBase64,
|
||||||
|
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const form: Partial<PhotoFormData> = {
|
||||||
|
...photoFormExif,
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
tags: tags || aiTags,
|
||||||
|
semanticDescription,
|
||||||
|
takenAt: photoFormExif.takenAt || takenAtLocal,
|
||||||
|
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedUrl = await convertUploadToPhoto(url);
|
||||||
|
if (updatedUrl) {
|
||||||
|
const photo = convertFormDataToPhotoDbInsert(form);
|
||||||
|
console.log(photo);
|
||||||
|
photo.url = updatedUrl;
|
||||||
|
await insertPhoto(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateAllKeysAndPaths();
|
||||||
|
redirect(PATH_ADMIN_PHOTOS);
|
||||||
|
});
|
||||||
|
|
||||||
export const updatePhotoAction = async (formData: FormData) =>
|
export const updatePhotoAction = async (formData: FormData) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(async () => {
|
||||||
const photo = convertFormDataToPhotoDbInsert(formData);
|
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||||
|
|||||||
@ -63,3 +63,11 @@ export const parseTitleAndCaption = (text: string) => {
|
|||||||
caption: matches?.[2] ?? '',
|
caption: matches?.[2] ?? '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cleanUpAiTextResponse = (text?: string) => text
|
||||||
|
? text
|
||||||
|
.replaceAll('\n', ' ')
|
||||||
|
.replaceAll('"', '')
|
||||||
|
.replace(/\.$/, '')
|
||||||
|
.trim()
|
||||||
|
: undefined;
|
||||||
|
|||||||
77
src/photo/ai/server.ts
Normal file
77
src/photo/ai/server.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { generateOpenAiImageQuery } from '@/services/openai';
|
||||||
|
import {
|
||||||
|
AI_IMAGE_QUERIES,
|
||||||
|
AiAutoGeneratedField,
|
||||||
|
cleanUpAiTextResponse,
|
||||||
|
parseTitleAndCaption,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
|
export const generateAiImageQueries = async (
|
||||||
|
imageBase64?: string,
|
||||||
|
textFieldsToGenerate: AiAutoGeneratedField[] = [],
|
||||||
|
): Promise<{
|
||||||
|
title?: string
|
||||||
|
caption?: string
|
||||||
|
tags?: string
|
||||||
|
semanticDescription?: string
|
||||||
|
}> => {
|
||||||
|
let title: string | undefined;
|
||||||
|
let caption: string | undefined;
|
||||||
|
let tags: string | undefined;
|
||||||
|
let semanticDescription: string | undefined;
|
||||||
|
|
||||||
|
if (imageBase64) {
|
||||||
|
if (
|
||||||
|
textFieldsToGenerate.includes('title') &&
|
||||||
|
textFieldsToGenerate.includes('caption')
|
||||||
|
) {
|
||||||
|
const titleAndCaption = await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['title-and-caption'],
|
||||||
|
);
|
||||||
|
if (titleAndCaption) {
|
||||||
|
const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption);
|
||||||
|
title = titleAndCaptionParsed.title;
|
||||||
|
caption = titleAndCaptionParsed.caption;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (textFieldsToGenerate.includes('title')) {
|
||||||
|
title = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['title'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (textFieldsToGenerate.includes('caption')) {
|
||||||
|
caption = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['caption'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textFieldsToGenerate.includes('tags')) {
|
||||||
|
tags = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['tags'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textFieldsToGenerate.includes('semantic')) {
|
||||||
|
semanticDescription = cleanUpAiTextResponse(
|
||||||
|
await generateOpenAiImageQuery(
|
||||||
|
imageBase64,
|
||||||
|
AI_IMAGE_QUERIES['description-small'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
tags,
|
||||||
|
semanticDescription,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@ export type AiContent = ReturnType<typeof useAiImageQueries>;
|
|||||||
|
|
||||||
export default function useAiImageQueries(
|
export default function useAiImageQueries(
|
||||||
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
|
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
|
||||||
imageData?: string,
|
imageBase64?: string,
|
||||||
) {
|
) {
|
||||||
const [
|
const [
|
||||||
requestTitleCaption,
|
requestTitleCaption,
|
||||||
@ -17,33 +17,33 @@ export default function useAiImageQueries(
|
|||||||
_isLoadingCaption,
|
_isLoadingCaption,
|
||||||
resetTitle,
|
resetTitle,
|
||||||
resetCaption,
|
resetCaption,
|
||||||
] = useTitleCaptionAiImageQuery(imageData);
|
] = useTitleCaptionAiImageQuery(imageBase64);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestTitle,
|
requestTitle,
|
||||||
titleSolo,
|
titleSolo,
|
||||||
isLoadingTitleSolo,
|
isLoadingTitleSolo,
|
||||||
resetTitleSolo,
|
resetTitleSolo,
|
||||||
] = useAiImageQuery(imageData, 'title');
|
] = useAiImageQuery(imageBase64, 'title');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestCaption,
|
requestCaption,
|
||||||
captionSolo,
|
captionSolo,
|
||||||
isLoadingCaptionSolo,
|
isLoadingCaptionSolo,
|
||||||
resetCaptionSolo,
|
resetCaptionSolo,
|
||||||
] = useAiImageQuery(imageData, 'caption');
|
] = useAiImageQuery(imageBase64, 'caption');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestTags,
|
requestTags,
|
||||||
tags,
|
tags,
|
||||||
isLoadingTags,
|
isLoadingTags,
|
||||||
] = useAiImageQuery(imageData, 'tags');
|
] = useAiImageQuery(imageBase64, 'tags');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
requestSemantic,
|
requestSemantic,
|
||||||
semanticDescription,
|
semanticDescription,
|
||||||
isLoadingSemantic,
|
isLoadingSemantic,
|
||||||
] = useAiImageQuery(imageData, 'description-small');
|
] = useAiImageQuery(imageBase64, 'description-small');
|
||||||
|
|
||||||
const title = _title || titleSolo;
|
const title = _title || titleSolo;
|
||||||
const caption = _caption || captionSolo;
|
const caption = _caption || captionSolo;
|
||||||
@ -99,12 +99,12 @@ export default function useAiImageQueries(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageData && !hasRunAllQueriesOnce.current) {
|
if (imageBase64 && !hasRunAllQueriesOnce.current) {
|
||||||
if (textFieldsToAutoGenerate.length > 0) {
|
if (textFieldsToAutoGenerate.length > 0) {
|
||||||
request(textFieldsToAutoGenerate);
|
request(textFieldsToAutoGenerate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [textFieldsToAutoGenerate, imageData, request]);
|
}, [textFieldsToAutoGenerate, imageBase64, request]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { streamAiImageQueryAction } from '../actions';
|
import { streamAiImageQueryAction } from '../actions';
|
||||||
import { readStreamableValue } from 'ai/rsc';
|
import { readStreamableValue } from 'ai/rsc';
|
||||||
import { AiImageQuery } from '.';
|
import { AiImageQuery, cleanUpAiTextResponse } from '.';
|
||||||
|
|
||||||
export default function useAiImageQuery(
|
export default function useAiImageQuery(
|
||||||
imageBase64: string | undefined,
|
imageBase64: string | undefined,
|
||||||
@ -21,10 +21,9 @@ export default function useAiImageQuery(
|
|||||||
query,
|
query,
|
||||||
);
|
);
|
||||||
for await (const text of readStreamableValue(textStream)) {
|
for await (const text of readStreamableValue(textStream)) {
|
||||||
setText(current => `${current}${text ?? ''}`
|
setText(current =>
|
||||||
.replaceAll('\n', ' ')
|
cleanUpAiTextResponse(`${current}${text ?? ''}`) ?? ''
|
||||||
.replaceAll('"', '')
|
);
|
||||||
.replace(/\.$/, ''));
|
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import useAiImageQuery from './useAiImageQuery';
|
|||||||
import { parseTitleAndCaption } from '.';
|
import { parseTitleAndCaption } from '.';
|
||||||
|
|
||||||
export default function useTitleCaptionAiImageQuery(
|
export default function useTitleCaptionAiImageQuery(
|
||||||
imageBase64: string | undefined,
|
imageBase64?: string,
|
||||||
) {
|
) {
|
||||||
const [
|
const [
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -19,8 +19,7 @@ import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
|||||||
import { toastSuccess, toastWarning } from '@/toast';
|
import { toastSuccess, toastWarning } from '@/toast';
|
||||||
import { getDimensionsFromSize } from '@/utility/size';
|
import { getDimensionsFromSize } from '@/utility/size';
|
||||||
import ImageWithFallback from '@/components/image/ImageWithFallback';
|
import ImageWithFallback from '@/components/image/ImageWithFallback';
|
||||||
import { TagsWithMeta, sortTagsObjectWithoutFavs } from '@/tag';
|
import { TagsWithMeta, convertTagsForForm } from '@/tag';
|
||||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
|
||||||
import { AiContent } from '../ai/useAiImageQueries';
|
import { AiContent } from '../ai/useAiImageQueries';
|
||||||
import AiButton from '../ai/AiButton';
|
import AiButton from '../ai/AiButton';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
@ -290,12 +289,7 @@ export default function PhotoForm({
|
|||||||
{/* Fields */}
|
{/* Fields */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{FORM_METADATA_ENTRIES(
|
{FORM_METADATA_ENTRIES(
|
||||||
sortTagsObjectWithoutFavs(uniqueTags ?? [])
|
convertTagsForForm(uniqueTags),
|
||||||
.map(({ tag, count }) => ({
|
|
||||||
value: tag,
|
|
||||||
annotation: formatCount(count),
|
|
||||||
annotationAria: formatCountDescriptive(count, 'tagged'),
|
|
||||||
})),
|
|
||||||
aiContent !== undefined,
|
aiContent !== undefined,
|
||||||
)
|
)
|
||||||
.map(([key, {
|
.map(([key, {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ExifData } from 'ts-exif-parser';
|
import type { ExifData } from 'ts-exif-parser';
|
||||||
import { Photo, PhotoDbInsert, PhotoExif } from '..';
|
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..';
|
||||||
import {
|
import {
|
||||||
convertTimestampToNaivePostgresString,
|
convertTimestampToNaivePostgresString,
|
||||||
convertTimestampWithOffsetToPostgresString,
|
convertTimestampWithOffsetToPostgresString,
|
||||||
@ -16,11 +16,11 @@ import {
|
|||||||
} from '@/vendors/fujifilm';
|
} from '@/vendors/fujifilm';
|
||||||
import { FilmSimulation } from '@/simulation';
|
import { FilmSimulation } from '@/simulation';
|
||||||
import { GEO_PRIVACY_ENABLED } from '@/site/config';
|
import { GEO_PRIVACY_ENABLED } from '@/site/config';
|
||||||
import { TAG_FAVS, TAG_HIDDEN, doesStringContainReservedTags } from '@/tag';
|
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
||||||
|
|
||||||
type VirtualFields = 'favorite';
|
type VirtualFields = 'favorite';
|
||||||
|
|
||||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
|
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
|
||||||
|
|
||||||
export type FieldSetType =
|
export type FieldSetType =
|
||||||
'text' |
|
'text' |
|
||||||
@ -76,9 +76,7 @@ const FORM_METADATA = (
|
|||||||
tags: {
|
tags: {
|
||||||
label: 'tags',
|
label: 'tags',
|
||||||
tagOptions,
|
tagOptions,
|
||||||
validate: tags => doesStringContainReservedTags(tags)
|
validate: getValidationMessageForTags,
|
||||||
? `Reserved tags (${TAG_FAVS}, ${TAG_HIDDEN})`
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
semanticDescription: {
|
semanticDescription: {
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
@ -219,8 +217,7 @@ export const convertExifToFormData = (
|
|||||||
// PREPARE FORM FOR DB INSERT
|
// PREPARE FORM FOR DB INSERT
|
||||||
|
|
||||||
export const convertFormDataToPhotoDbInsert = (
|
export const convertFormDataToPhotoDbInsert = (
|
||||||
formData: FormData | PhotoFormData,
|
formData: FormData | Partial<PhotoFormData>,
|
||||||
generateId?: boolean,
|
|
||||||
): PhotoDbInsert => {
|
): PhotoDbInsert => {
|
||||||
const photoForm = formData instanceof FormData
|
const photoForm = formData instanceof FormData
|
||||||
? Object.fromEntries(formData) as PhotoFormData
|
? Object.fromEntries(formData) as PhotoFormData
|
||||||
@ -247,11 +244,13 @@ export const convertFormDataToPhotoDbInsert = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
||||||
...(generateId && !photoForm.id) && { id: generateNanoid() },
|
...!photoForm.id && { id: generateNanoid() },
|
||||||
// Convert form strings to arrays
|
// Convert form strings to arrays
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
// Convert form strings to numbers
|
// Convert form strings to numbers
|
||||||
aspectRatio: roundToNumber(parseFloat(photoForm.aspectRatio), 6),
|
aspectRatio: photoForm.aspectRatio
|
||||||
|
? roundToNumber(parseFloat(photoForm.aspectRatio), 6)
|
||||||
|
: DEFAULT_ASPECT_RATIO,
|
||||||
focalLength: photoForm.focalLength
|
focalLength: photoForm.focalLength
|
||||||
? parseInt(photoForm.focalLength)
|
? parseInt(photoForm.focalLength)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@ -31,6 +31,8 @@ export const INFINITE_SCROLL_GRID_PHOTO_MULTIPLE = HIGH_DENSITY_GRID
|
|||||||
// Thumbnails below /p/[photoId]
|
// Thumbnails below /p/[photoId]
|
||||||
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
|
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
|
||||||
|
|
||||||
|
export const DEFAULT_ASPECT_RATIO = 1.5;
|
||||||
|
|
||||||
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
||||||
'image/jpg',
|
'image/jpg',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
'use server';
|
import { generateText, streamText } from 'ai';
|
||||||
|
|
||||||
import { streamText } from 'ai';
|
|
||||||
import { createStreamableValue } from 'ai/rsc';
|
import { createStreamableValue } from 'ai/rsc';
|
||||||
import { createOpenAI } from '@ai-sdk/openai';
|
import { createOpenAI } from '@ai-sdk/openai';
|
||||||
import { kv } from '@vercel/kv';
|
import { kv } from '@vercel/kv';
|
||||||
import { Ratelimit } from '@upstash/ratelimit';
|
import { Ratelimit } from '@upstash/ratelimit';
|
||||||
import { AI_TEXT_GENERATION_ENABLED, HAS_VERCEL_KV } from '@/site/config';
|
import { AI_TEXT_GENERATION_ENABLED, HAS_VERCEL_KV } from '@/site/config';
|
||||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
|
||||||
import { removeBase64Prefix } from '@/utility/image';
|
import { removeBase64Prefix } from '@/utility/image';
|
||||||
|
|
||||||
const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
|
const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
|
||||||
@ -28,47 +25,82 @@ export const streamOpenAiImageQuery = async (
|
|||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
query: string,
|
query: string,
|
||||||
) => {
|
) => {
|
||||||
return runAuthenticatedAdminServerAction(async () => {
|
if (ratelimit) {
|
||||||
if (ratelimit) {
|
let success = false;
|
||||||
let success = false;
|
try {
|
||||||
try {
|
success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success;
|
||||||
success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success;
|
} catch (e: any) {
|
||||||
} catch (e: any) {
|
console.error('Failed to rate limit OpenAI', e);
|
||||||
console.error('Failed to rate limit OpenAI', e);
|
throw new Error('Failed to rate limit OpenAI');
|
||||||
throw new Error('Failed to rate limit OpenAI');
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
console.error('OpenAI rate limit exceeded');
|
|
||||||
throw new Error('OpenAI rate limit exceeded');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!success) {
|
||||||
const stream = createStreamableValue('');
|
console.error('OpenAI rate limit exceeded');
|
||||||
|
throw new Error('OpenAI rate limit exceeded');
|
||||||
if (openai) {
|
|
||||||
(async () => {
|
|
||||||
const { textStream } = await streamText({
|
|
||||||
model: openai('gpt-4-vision-preview'),
|
|
||||||
messages: [{
|
|
||||||
'role': 'user',
|
|
||||||
'content': [
|
|
||||||
{
|
|
||||||
'type': 'text',
|
|
||||||
'text': query,
|
|
||||||
}, {
|
|
||||||
'type': 'image',
|
|
||||||
'image': removeBase64Prefix(imageBase64),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
for await (const delta of textStream) {
|
|
||||||
stream.update(delta);
|
|
||||||
}
|
|
||||||
stream.done();
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return stream.value;
|
const stream = createStreamableValue('');
|
||||||
});
|
|
||||||
|
if (openai) {
|
||||||
|
(async () => {
|
||||||
|
const { textStream } = await streamText({
|
||||||
|
model: openai('gpt-4-vision-preview'),
|
||||||
|
messages: [{
|
||||||
|
'role': 'user',
|
||||||
|
'content': [
|
||||||
|
{
|
||||||
|
'type': 'text',
|
||||||
|
'text': query,
|
||||||
|
}, {
|
||||||
|
'type': 'image',
|
||||||
|
'image': removeBase64Prefix(imageBase64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
for await (const delta of textStream) {
|
||||||
|
stream.update(delta);
|
||||||
|
}
|
||||||
|
stream.done();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateOpenAiImageQuery = async (
|
||||||
|
imageBase64: string,
|
||||||
|
query: string,
|
||||||
|
) => {
|
||||||
|
if (ratelimit) {
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
|
success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to rate limit OpenAI', e);
|
||||||
|
throw new Error('Failed to rate limit OpenAI');
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
console.error('OpenAI rate limit exceeded');
|
||||||
|
throw new Error('OpenAI rate limit exceeded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openai) {
|
||||||
|
return generateText({
|
||||||
|
model: openai('gpt-4-vision-preview'),
|
||||||
|
messages: [{
|
||||||
|
'role': 'user',
|
||||||
|
'content': [
|
||||||
|
{
|
||||||
|
'type': 'text',
|
||||||
|
'text': query,
|
||||||
|
}, {
|
||||||
|
'type': 'image',
|
||||||
|
'image': removeBase64Prefix(imageBase64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}).then(({ text }) => text);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,7 +9,12 @@ import {
|
|||||||
absolutePathForTagImage,
|
absolutePathForTagImage,
|
||||||
getPathComponents,
|
getPathComponents,
|
||||||
} from '@/site/paths';
|
} from '@/site/paths';
|
||||||
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
import {
|
||||||
|
capitalizeWords,
|
||||||
|
convertStringToArray,
|
||||||
|
formatCount,
|
||||||
|
formatCountDescriptive,
|
||||||
|
} from '@/utility/string';
|
||||||
|
|
||||||
// Reserved tags
|
// Reserved tags
|
||||||
export const TAG_FAVS = 'favs';
|
export const TAG_FAVS = 'favs';
|
||||||
@ -23,8 +28,14 @@ export type TagsWithMeta = {
|
|||||||
export const formatTag = (tag?: string) =>
|
export const formatTag = (tag?: string) =>
|
||||||
capitalizeWords(tag?.replaceAll('-', ' '));
|
capitalizeWords(tag?.replaceAll('-', ' '));
|
||||||
|
|
||||||
export const doesStringContainReservedTags = (tags?: string) =>
|
export const getValidationMessageForTags = (tags?: string) => {
|
||||||
convertStringToArray(tags)?.some(tag => isTagFavs(tag) || isTagHidden(tag));
|
const reservedTags = (convertStringToArray(tags) ?? [])
|
||||||
|
.filter(tag => isTagFavs(tag) || isTagHidden(tag))
|
||||||
|
.map(tag => tag.toLocaleUpperCase());
|
||||||
|
return reservedTags.length
|
||||||
|
? `Reserved tags: ${reservedTags.join(', ').toLocaleLowerCase()}`
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const titleForTag = (
|
export const titleForTag = (
|
||||||
tag: string,
|
tag: string,
|
||||||
@ -85,7 +96,7 @@ export const generateMetaForTag = (
|
|||||||
images: absolutePathForTagImage(tag),
|
images: absolutePathForTagImage(tag),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const isTagFavs = (tag: string) => tag.toLowerCase() === TAG_FAVS;
|
export const isTagFavs = (tag: string) => tag.toLocaleLowerCase() === TAG_FAVS;
|
||||||
|
|
||||||
export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs);
|
||||||
|
|
||||||
@ -104,3 +115,11 @@ export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => {
|
|||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const convertTagsForForm = (tags: TagsWithMeta = []) =>
|
||||||
|
sortTagsObjectWithoutFavs(tags)
|
||||||
|
.map(({ tag, count }) => ({
|
||||||
|
value: tag,
|
||||||
|
annotation: formatCount(count),
|
||||||
|
annotationAria: formatCountDescriptive(count, 'tagged'),
|
||||||
|
}));
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export const convertTimestampToNaivePostgresString = (
|
|||||||
'$1 $2',
|
'$1 $2',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run in the browser, to get generate local date time strings
|
// Run in browser to generate local date time strings
|
||||||
|
|
||||||
export const generateLocalPostgresString = () =>
|
export const generateLocalPostgresString = () =>
|
||||||
formatDateForPostgres(new Date());
|
formatDateForPostgres(new Date());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user