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?
|
||||
> 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 { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||
import SiteGrid from '@/components/SiteGrid';
|
||||
import AdminAddAllUploads from '@/admin/AdminAddAllUploads';
|
||||
import { getUniqueTagsCached } from '@/photo/cache';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
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
|
||||
@ -139,12 +149,18 @@ export default function FieldSetWithStatus({
|
||||
autoComplete="off"
|
||||
autoCapitalize={!capitalize ? 'off' : undefined}
|
||||
readOnly={readOnly || pending || loading}
|
||||
disabled={type === 'checkbox' && (
|
||||
readOnly || pending || loading
|
||||
)}
|
||||
className={clsx(
|
||||
(
|
||||
type === 'text' ||
|
||||
type === 'email' ||
|
||||
type === 'password'
|
||||
) && 'w-full',
|
||||
type === 'checkbox' && (
|
||||
readOnly || pending || loading
|
||||
) && 'opacity-50 cursor-not-allowed',
|
||||
Boolean(error) && 'error',
|
||||
)}
|
||||
/>}
|
||||
|
||||
@ -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,13 +41,19 @@ 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_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
|
||||
|
||||
export const createPhotoAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const photo = convertFormDataToPhotoDbInsert(formData, true);
|
||||
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||
|
||||
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) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||
|
||||
@ -63,3 +63,11 @@ export const parseTitleAndCaption = (text: string) => {
|
||||
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(
|
||||
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
|
||||
imageData?: string,
|
||||
imageBase64?: string,
|
||||
) {
|
||||
const [
|
||||
requestTitleCaption,
|
||||
@ -17,33 +17,33 @@ export default function useAiImageQueries(
|
||||
_isLoadingCaption,
|
||||
resetTitle,
|
||||
resetCaption,
|
||||
] = useTitleCaptionAiImageQuery(imageData);
|
||||
] = useTitleCaptionAiImageQuery(imageBase64);
|
||||
|
||||
const [
|
||||
requestTitle,
|
||||
titleSolo,
|
||||
isLoadingTitleSolo,
|
||||
resetTitleSolo,
|
||||
] = useAiImageQuery(imageData, 'title');
|
||||
] = useAiImageQuery(imageBase64, 'title');
|
||||
|
||||
const [
|
||||
requestCaption,
|
||||
captionSolo,
|
||||
isLoadingCaptionSolo,
|
||||
resetCaptionSolo,
|
||||
] = useAiImageQuery(imageData, 'caption');
|
||||
] = useAiImageQuery(imageBase64, 'caption');
|
||||
|
||||
const [
|
||||
requestTags,
|
||||
tags,
|
||||
isLoadingTags,
|
||||
] = useAiImageQuery(imageData, 'tags');
|
||||
] = useAiImageQuery(imageBase64, 'tags');
|
||||
|
||||
const [
|
||||
requestSemantic,
|
||||
semanticDescription,
|
||||
isLoadingSemantic,
|
||||
] = useAiImageQuery(imageData, 'description-small');
|
||||
] = useAiImageQuery(imageBase64, 'description-small');
|
||||
|
||||
const title = _title || titleSolo;
|
||||
const caption = _caption || captionSolo;
|
||||
@ -99,12 +99,12 @@ export default function useAiImageQueries(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (imageData && !hasRunAllQueriesOnce.current) {
|
||||
if (imageBase64 && !hasRunAllQueriesOnce.current) {
|
||||
if (textFieldsToAutoGenerate.length > 0) {
|
||||
request(textFieldsToAutoGenerate);
|
||||
}
|
||||
}
|
||||
}, [textFieldsToAutoGenerate, imageData, request]);
|
||||
}, [textFieldsToAutoGenerate, imageBase64, request]);
|
||||
|
||||
return {
|
||||
request,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { streamAiImageQueryAction } from '../actions';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import { AiImageQuery } from '.';
|
||||
import { AiImageQuery, cleanUpAiTextResponse } from '.';
|
||||
|
||||
export default function useAiImageQuery(
|
||||
imageBase64: string | undefined,
|
||||
@ -21,10 +21,9 @@ export default function useAiImageQuery(
|
||||
query,
|
||||
);
|
||||
for await (const text of readStreamableValue(textStream)) {
|
||||
setText(current => `${current}${text ?? ''}`
|
||||
.replaceAll('\n', ' ')
|
||||
.replaceAll('"', '')
|
||||
.replace(/\.$/, ''));
|
||||
setText(current =>
|
||||
cleanUpAiTextResponse(`${current}${text ?? ''}`) ?? ''
|
||||
);
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
|
||||
@ -3,7 +3,7 @@ import useAiImageQuery from './useAiImageQuery';
|
||||
import { parseTitleAndCaption } from '.';
|
||||
|
||||
export default function useTitleCaptionAiImageQuery(
|
||||
imageBase64: string | undefined,
|
||||
imageBase64?: string,
|
||||
) {
|
||||
const [
|
||||
request,
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ExifData } from 'ts-exif-parser';
|
||||
import { Photo, PhotoDbInsert, PhotoExif } from '..';
|
||||
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..';
|
||||
import {
|
||||
convertTimestampToNaivePostgresString,
|
||||
convertTimestampWithOffsetToPostgresString,
|
||||
@ -16,11 +16,11 @@ 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';
|
||||
|
||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>;
|
||||
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
|
||||
|
||||
export type FieldSetType =
|
||||
'text' |
|
||||
@ -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',
|
||||
@ -219,8 +217,7 @@ export const convertExifToFormData = (
|
||||
// PREPARE FORM FOR DB INSERT
|
||||
|
||||
export const convertFormDataToPhotoDbInsert = (
|
||||
formData: FormData | PhotoFormData,
|
||||
generateId?: boolean,
|
||||
formData: FormData | Partial<PhotoFormData>,
|
||||
): PhotoDbInsert => {
|
||||
const photoForm = formData instanceof FormData
|
||||
? Object.fromEntries(formData) as PhotoFormData
|
||||
@ -247,11 +244,13 @@ export const convertFormDataToPhotoDbInsert = (
|
||||
|
||||
return {
|
||||
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
|
||||
...(generateId && !photoForm.id) && { id: generateNanoid() },
|
||||
...!photoForm.id && { id: generateNanoid() },
|
||||
// Convert form strings to arrays
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
// 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
|
||||
? parseInt(photoForm.focalLength)
|
||||
: undefined,
|
||||
|
||||
@ -31,6 +31,8 @@ export const INFINITE_SCROLL_GRID_PHOTO_MULTIPLE = HIGH_DENSITY_GRID
|
||||
// Thumbnails below /p/[photoId]
|
||||
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
|
||||
|
||||
export const DEFAULT_ASPECT_RATIO = 1.5;
|
||||
|
||||
export const ACCEPTED_PHOTO_FILE_TYPES = [
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
|
||||
@ -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),
|
||||
},
|
||||
],
|
||||
}],
|
||||
}).then(({ text }) => text);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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