Introduce multiple uploads component
This commit is contained in:
parent
64d6608a79
commit
3039076e27
124
src/admin/AdminAddAllUploads.tsx
Normal file
124
src/admin/AdminAddAllUploads.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import ErrorNote from '@/components/ErrorNote';
|
||||
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
|
||||
import InfoBlock from '@/components/InfoBlock';
|
||||
import LoaderButton from '@/components/primitives/LoaderButton';
|
||||
import { addAllUploads } from '@/photo/actions';
|
||||
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
|
||||
import {
|
||||
TagsWithMeta,
|
||||
convertTagsForForm,
|
||||
getValidationMessageForTags,
|
||||
} from '@/tag';
|
||||
import {
|
||||
generateLocalNaivePostgresString,
|
||||
generateLocalPostgresString,
|
||||
} from '@/utility/date';
|
||||
import { convertStringToArray } from '@/utility/string';
|
||||
import clsx from 'clsx';
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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) ?? '');
|
||||
}}
|
||||
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);
|
||||
addAllUploads({
|
||||
tags: showTags && tags
|
||||
? convertStringToArray(tags) ?? []
|
||||
: [],
|
||||
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,22 @@
|
||||
import AdminUploadsTable from '@/admin/AdminUploadsTable';
|
||||
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import AdminAddAllUploads from '@/admin/AdminAddAllUploads';
|
||||
import { getUniqueTagsCached } from '@/photo/cache';
|
||||
|
||||
export default async function AdminUploadsPage() {
|
||||
const storageUrls = await getStorageUploadUrlsNoStore();
|
||||
const uniqueTags = await getUniqueTagsCached();
|
||||
return (
|
||||
<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';
|
||||
|
||||
export default function ErrorNote({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-3',
|
||||
'flex w-full items-center gap-3',
|
||||
'px-3 py-2 border',
|
||||
'text-red-600 dark:text-red-500/90',
|
||||
'bg-red-50/50 dark:bg-red-950/50',
|
||||
'border-red-100 dark:border-red-950',
|
||||
'rounded-md',
|
||||
className,
|
||||
)}>
|
||||
<BiErrorAlt
|
||||
size={18}
|
||||
|
||||
@ -26,6 +26,7 @@ export default function FieldSetWithStatus({
|
||||
type = 'text',
|
||||
inputRef,
|
||||
accessory,
|
||||
hideLabel,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
@ -45,39 +46,47 @@ export default function FieldSetWithStatus({
|
||||
type?: FieldSetType
|
||||
inputRef?: LegacyRef<HTMLInputElement>
|
||||
accessory?: React.ReactNode
|
||||
hideLabel?: boolean
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
className="flex gap-2 items-center select-none"
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
{note && !error &&
|
||||
<span className="text-gray-400 dark:text-gray-600">
|
||||
({note})
|
||||
</span>}
|
||||
{isModified && !error &&
|
||||
<span className={clsx(
|
||||
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
|
||||
)}>
|
||||
*
|
||||
</span>}
|
||||
{error &&
|
||||
<span className="text-error">
|
||||
{error}
|
||||
</span>}
|
||||
{required &&
|
||||
<span className="text-gray-400 dark:text-gray-600">
|
||||
Required
|
||||
</span>}
|
||||
{loading &&
|
||||
<span className="translate-y-[1.5px]">
|
||||
<Spinner />
|
||||
</span>}
|
||||
</label>
|
||||
<div className={clsx(
|
||||
'space-y-1',
|
||||
type === 'checkbox' && 'flex items-center gap-2',
|
||||
)}>
|
||||
{!hideLabel &&
|
||||
<label
|
||||
className={clsx(
|
||||
'flex gap-2 items-center select-none',
|
||||
type === 'checkbox' && 'order-2 pt-[3px]',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
{note && !error &&
|
||||
<span className="text-gray-400 dark:text-gray-600">
|
||||
({note})
|
||||
</span>}
|
||||
{isModified && !error &&
|
||||
<span className={clsx(
|
||||
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
|
||||
)}>
|
||||
*
|
||||
</span>}
|
||||
{error &&
|
||||
<span className="text-error">
|
||||
{error}
|
||||
</span>}
|
||||
{required &&
|
||||
<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">
|
||||
{selectOptions
|
||||
? <select
|
||||
@ -111,6 +120,7 @@ export default function FieldSetWithStatus({
|
||||
onChange={onChange}
|
||||
className={clsx(Boolean(error) && 'error')}
|
||||
readOnly={readOnly || pending || loading}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
: type === 'textarea'
|
||||
? <textarea
|
||||
|
||||
@ -17,6 +17,7 @@ export default function TagInput({
|
||||
onChange,
|
||||
className,
|
||||
readOnly,
|
||||
placeholder,
|
||||
}: {
|
||||
id?: string
|
||||
name: string
|
||||
@ -25,6 +26,7 @@ export default function TagInput({
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
readOnly?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
const containerRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@ -239,6 +241,7 @@ export default function TagInput({
|
||||
role="button"
|
||||
aria-label={`Remove tag "${option}"`}
|
||||
className={clsx(
|
||||
'text-main',
|
||||
'cursor-pointer select-none',
|
||||
'whitespace-nowrap',
|
||||
'px-1.5 py-0.5',
|
||||
@ -257,6 +260,7 @@ export default function TagInput({
|
||||
className={clsx(
|
||||
'grow !min-w-0 !p-0 -my-2 text-xl',
|
||||
'!border-none !ring-transparent',
|
||||
'placeholder:text-dim',
|
||||
)}
|
||||
size={10}
|
||||
value={inputText}
|
||||
@ -264,6 +268,7 @@ export default function TagInput({
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
readOnly={readOnly}
|
||||
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
|
||||
onFocus={() => setSelectedOptionIndex(undefined)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={shouldShowMenu}
|
||||
|
||||
@ -41,7 +41,8 @@ import { convertPhotoToPhotoDbInsert } from '.';
|
||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
||||
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
|
||||
import { streamOpenAiImageQuery } from '@/services/openai';
|
||||
import { BLUR_ENABLED } from '@/site/config';
|
||||
import { AI_TEXT_GENERATION_ENABLED, BLUR_ENABLED } from '@/site/config';
|
||||
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||
|
||||
// Private actions
|
||||
|
||||
@ -59,6 +60,47 @@ export const createPhotoAction = async (formData: FormData) =>
|
||||
}
|
||||
});
|
||||
|
||||
export const addAllUploads = 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 form = {
|
||||
...photoFormExif,
|
||||
tags,
|
||||
takenAt: photoFormExif.takenAt || takenAtLocal,
|
||||
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
|
||||
};
|
||||
}
|
||||
// const updatedUrl = await convertUploadToPhoto(url);
|
||||
// if (updatedUrl) {
|
||||
// const photo = convertFormDataToPhotoDbInsert(new FormData(), true);
|
||||
// photo.url = updatedUrl;
|
||||
// await insertPhoto(photo);
|
||||
// }
|
||||
// const photo = convertFormDataToPhotoDbInsert(new FormData(), true);
|
||||
// photo.url = url;
|
||||
// await insertPhoto(photo);
|
||||
}
|
||||
});
|
||||
|
||||
export const updatePhotoAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||
|
||||
@ -19,8 +19,7 @@ import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
|
||||
import { toastSuccess, toastWarning } from '@/toast';
|
||||
import { getDimensionsFromSize } from '@/utility/size';
|
||||
import ImageWithFallback from '@/components/image/ImageWithFallback';
|
||||
import { TagsWithMeta, sortTagsObjectWithoutFavs } from '@/tag';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
import { TagsWithMeta, convertTagsForForm } from '@/tag';
|
||||
import { AiContent } from '../ai/useAiImageQueries';
|
||||
import AiButton from '../ai/AiButton';
|
||||
import Spinner from '@/components/Spinner';
|
||||
@ -290,12 +289,7 @@ export default function PhotoForm({
|
||||
{/* Fields */}
|
||||
<div className="space-y-6">
|
||||
{FORM_METADATA_ENTRIES(
|
||||
sortTagsObjectWithoutFavs(uniqueTags ?? [])
|
||||
.map(({ tag, count }) => ({
|
||||
value: tag,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count, 'tagged'),
|
||||
})),
|
||||
convertTagsForForm(uniqueTags),
|
||||
aiContent !== undefined,
|
||||
)
|
||||
.map(([key, {
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
} from '@/vendors/fujifilm';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import { GEO_PRIVACY_ENABLED } from '@/site/config';
|
||||
import { TAG_FAVS, TAG_HIDDEN, doesStringContainReservedTags } from '@/tag';
|
||||
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
||||
|
||||
type VirtualFields = 'favorite';
|
||||
|
||||
@ -76,9 +76,7 @@ const FORM_METADATA = (
|
||||
tags: {
|
||||
label: 'tags',
|
||||
tagOptions,
|
||||
validate: tags => doesStringContainReservedTags(tags)
|
||||
? `Reserved tags (${TAG_FAVS}, ${TAG_HIDDEN})`
|
||||
: undefined,
|
||||
validate: getValidationMessageForTags,
|
||||
},
|
||||
semanticDescription: {
|
||||
type: 'textarea',
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
'use server';
|
||||
|
||||
import { streamText } from 'ai';
|
||||
import { generateText, streamText } from 'ai';
|
||||
import { createStreamableValue } from 'ai/rsc';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { kv } from '@vercel/kv';
|
||||
import { Ratelimit } from '@upstash/ratelimit';
|
||||
import { AI_TEXT_GENERATION_ENABLED, HAS_VERCEL_KV } from '@/site/config';
|
||||
import { runAuthenticatedAdminServerAction } from '@/auth';
|
||||
import { removeBase64Prefix } from '@/utility/image';
|
||||
|
||||
const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
|
||||
@ -28,47 +25,82 @@ export const streamOpenAiImageQuery = async (
|
||||
imageBase64: string,
|
||||
query: string,
|
||||
) => {
|
||||
return runAuthenticatedAdminServerAction(async () => {
|
||||
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 (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');
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
if (!success) {
|
||||
console.error('OpenAI rate limit exceeded');
|
||||
throw new Error('OpenAI rate limit exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -9,7 +9,12 @@ import {
|
||||
absolutePathForTagImage,
|
||||
getPathComponents,
|
||||
} from '@/site/paths';
|
||||
import { capitalizeWords, convertStringToArray } from '@/utility/string';
|
||||
import {
|
||||
capitalizeWords,
|
||||
convertStringToArray,
|
||||
formatCount,
|
||||
formatCountDescriptive,
|
||||
} from '@/utility/string';
|
||||
|
||||
// Reserved tags
|
||||
export const TAG_FAVS = 'favs';
|
||||
@ -23,8 +28,14 @@ export type TagsWithMeta = {
|
||||
export const formatTag = (tag?: string) =>
|
||||
capitalizeWords(tag?.replaceAll('-', ' '));
|
||||
|
||||
export const doesStringContainReservedTags = (tags?: string) =>
|
||||
convertStringToArray(tags)?.some(tag => isTagFavs(tag) || isTagHidden(tag));
|
||||
export const getValidationMessageForTags = (tags?: string) => {
|
||||
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 = (
|
||||
tag: string,
|
||||
@ -85,7 +96,7 @@ export const generateMetaForTag = (
|
||||
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);
|
||||
|
||||
@ -104,3 +115,11 @@ export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => {
|
||||
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',
|
||||
);
|
||||
|
||||
// Run in the browser, to get generate local date time strings
|
||||
// Run in browser to generate local date time strings
|
||||
|
||||
export const generateLocalPostgresString = () =>
|
||||
formatDateForPostgres(new Date());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user