Merge pull request #99 from sambecker/multiple-uploads

Add multiple uploads at once
This commit is contained in:
Sam Becker 2024-05-27 11:14:04 -05:00 committed by GitHub
commit ed5e041c77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 469 additions and 114 deletions

View File

@ -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`.

View 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>
</>
);
}

View File

@ -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>}
/> />
); );
} }

View File

@ -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}

View File

@ -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',
)} )}
/>} />}

View File

@ -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}

View File

@ -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);

View File

@ -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
View 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,
};
};

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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, {

View File

@ -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,

View File

@ -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',

View File

@ -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);
}
}; };

View File

@ -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'),
}));

View File

@ -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());