Introduce multiple uploads component

This commit is contained in:
Sam Becker 2024-05-26 22:40:02 -05:00
parent 64d6608a79
commit 3039076e27
11 changed files with 330 additions and 93 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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