Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-04-08 22:51:39 -05:00
commit 5af688b9bf
12 changed files with 504 additions and 384 deletions

View File

@ -9,8 +9,8 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@aws-sdk/client-s3": "3.540.0",
"@aws-sdk/s3-request-presigner": "3.540.0",
"@aws-sdk/client-s3": "3.550.0",
"@aws-sdk/s3-request-presigner": "3.550.0",
"@next/bundle-analyzer": "14.1.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@tailwindcss/container-queries": "^0.1.1",
@ -18,18 +18,18 @@
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.3",
"@types/node": "^20.12.5",
"@types/react": "18.2.74",
"@types/react-dom": "18.2.23",
"@types/react-dom": "18.2.24",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@upstash/ratelimit": "^1.0.3",
"@vercel/analytics": "^1.2.2",
"@vercel/blob": "^0.22.1",
"@vercel/blob": "^0.22.2",
"@vercel/kv": "^1.0.1",
"@vercel/postgres": "0.8.0",
"@vercel/speed-insights": "^1.0.10",
"ai": "^3.0.17",
"ai": "^3.0.19",
"autoprefixer": "10.4.19",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.0",
@ -38,14 +38,14 @@
"eslint": "8.57.0",
"eslint-config-next": "14.1.4",
"exifr": "^7.1.3",
"framer-motion": "^11.0.24",
"framer-motion": "^11.0.25",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.6",
"next": "14.2.0-canary.54",
"next": "14.2.0-canary.63",
"next-auth": "5.0.0-beta.15",
"next-themes": "^0.3.0",
"openai": "^4.32.1",
"openai": "^4.33.0",
"postcss": "8.4.38",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -53,7 +53,7 @@
"sonner": "^1.4.41",
"tailwindcss": "3.4.3",
"ts-exif-parser": "^0.2.2",
"typescript": "5.4.3",
"typescript": "5.4.4",
"use-debounce": "^10.0.0"
}
}

575
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
const {
blobId,
photoFormExif,
} = await extractExifDataFromBlobPath(uploadPath);
} = await extractExifDataFromBlobPath(uploadPath, true);
if (!photoFormExif) { redirect(PATH_ADMIN); }

View File

@ -13,6 +13,7 @@ export default function FieldSetWithStatus({
note,
error,
value,
isModified,
onChange,
selectOptions,
selectOptionsDefaultLabel,
@ -31,6 +32,7 @@ export default function FieldSetWithStatus({
note?: string
error?: string
value: string
isModified?: boolean
onChange?: (value: string) => void
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
@ -57,6 +59,12 @@ export default function FieldSetWithStatus({
<span className="text-gray-400 dark:text-gray-600">
({note})
</span>}
{isModified &&
<span className={clsx(
'text-main font-medium text-[0.9rem] -ml-1.5 translate-y-[-1px]'
)}>
*
</span>}
{error &&
<span className="text-error">
{error}

View File

@ -12,6 +12,7 @@ interface Props extends HTMLProps<HTMLButtonElement> {
spinnerColor?: SpinnerColor
onFormStatusChange?: (pending: boolean) => void
onFormSubmitToastMessage?: string
primary?: boolean
}
export default function SubmitButtonWithStatus({
@ -23,6 +24,7 @@ export default function SubmitButtonWithStatus({
children,
disabled,
className,
primary,
type: _type,
...buttonProps
}: Props) {
@ -52,6 +54,7 @@ export default function SubmitButtonWithStatus({
className={clsx(
className,
'inline-flex items-center gap-2',
primary && 'primary',
styleAsLink && 'link',
)}
{...buttonProps}

View File

@ -79,7 +79,7 @@ export default function PhotoLarge({
'pb-6',
)}>
{/* Meta */}
<div className="pr-3 md:pr-0">
<div className="pr-2 md:pr-0">
<div className="md:relative flex gap-2 items-start">
<div className="flex-grow">
<Link

View File

@ -2,12 +2,13 @@
import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_UPLOADS } from '@/site/paths';
import { PhotoFormData } from './form';
import { PhotoFormData, generateTakenAtFields } from './form';
import PhotoForm from './form/PhotoForm';
import { TagsWithMeta } from '@/tag';
import usePhotoFormParent from './form/usePhotoFormParent';
import AiButton from './ai/AiButton';
import { AiAutoGeneratedField } from './ai';
import { useMemo } from 'react';
export default function UploadPageClient({
blobId,
@ -32,6 +33,12 @@ export default function UploadPageClient({
aiContent,
} = usePhotoFormParent({ textFieldsToAutoGenerate });
const initialPhotoForm = useMemo(() => ({
...photoFormExif,
// Generate missing dates on client to avoid timezone issues
...generateTakenAtFields(photoFormExif),
}), [photoFormExif]);
return (
<AdminChildPage
backPath={PATH_ADMIN_UPLOADS}
@ -45,7 +52,7 @@ export default function UploadPageClient({
isLoading={pending}
>
<PhotoForm
initialPhotoForm={photoFormExif}
initialPhotoForm={initialPhotoForm}
uniqueTags={uniqueTags}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}

View File

@ -1,11 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
FORM_METADATA_ENTRIES,
PhotoFormData,
convertFormKeysToLabels,
formHasTextContent,
getChangedFormFields,
getFormErrors,
isFormValid,
} from '.';
@ -16,10 +17,6 @@ import Link from 'next/link';
import { clsx } from 'clsx/lite';
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths';
import {
generateLocalNaivePostgresString,
generateLocalPostgresString,
} from '@/utility/date';
import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
@ -31,6 +28,7 @@ import AiButton from '../ai/AiButton';
import Spinner from '@/components/Spinner';
import { getNextImageUrlForRequest } from '@/services/next-image';
import useDelay from '@/utility/useDelay';
import usePreventNavigation from '@/utility/usePreventNavigation';
const THUMBNAIL_SIZE = 300;
@ -63,6 +61,21 @@ export default function PhotoForm({
const [blurError, setBlurError] =
useState<string>();
const [hasBlurData, setHasBlurData] = useState(false);
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
[initialPhotoForm, formData]);
const formHasChanged = changedFormKeys.length > 0;
const onlyChangedFieldIsBlurData =
changedFormKeys.length === 1 &&
changedFormKeys[0] === 'blurData';
usePreventNavigation(formHasChanged && !onlyChangedFieldIsBlurData);
const canFormBeSubmitted =
(type === 'create' || formHasChanged) &&
isFormValid(formData) &&
!aiContent?.isLoading;
const didLoad1000msAgo = useDelay(1000);
@ -112,22 +125,6 @@ export default function PhotoForm({
height,
} = getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio);
// Generate local date strings when
// none can be extracted from EXIF
useEffect(() => {
if (!formData.takenAt || !formData.takenAtNaive) {
setFormData(data => ({
...data,
...!formData.takenAt && {
takenAt: generateLocalPostgresString(),
},
...!formData.takenAtNaive && {
takenAtNaive: generateLocalNaivePostgresString(),
},
}));
}
}, [formData.takenAt, formData.takenAtNaive]);
const url = formData.url ?? '';
const updateBlurData = useCallback((blurData: string) => {
@ -218,7 +215,7 @@ export default function PhotoForm({
};
return (
<div className="space-y-8 max-w-[38rem]">
<div className="space-y-8 max-w-[38rem] relative">
{debugBlur && blurError &&
<div className="border error text-error rounded-md px-2 py-1">
{blurError}
@ -282,77 +279,84 @@ export default function PhotoForm({
<form
action={type === 'create' ? createPhotoAction : updatePhotoAction}
onSubmit={() => blur()}
className="space-y-6"
>
{FORM_METADATA_ENTRIES(
sortTagsObjectWithoutFavs(uniqueTags ?? [])
.map(({ tag, count }) => ({
value: tag,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count, 'tagged'),
})),
aiContent !== undefined,
)
.map(([key, {
label,
note,
required,
selectOptions,
selectOptionsDefaultLabel,
tagOptions,
readOnly,
validate,
validateStringMaxLength,
capitalize,
hideIfEmpty,
shouldHide,
loadingMessage,
type,
}]) =>
(
(!hideIfEmpty || formData[key]) &&
!shouldHide?.(formData)
) &&
<FieldSetWithStatus
key={key}
id={key}
label={label}
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}
onChange={value => {
const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated);
if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) });
} else if (validateStringMaxLength !== undefined) {
setFormErrors({
...formErrors,
[key]: value.length > validateStringMaxLength
? `${validateStringMaxLength} characters or less`
: undefined,
});
}
if (key === 'title') {
onTitleChange?.(value.trim());
}
}}
selectOptions={selectOptions}
selectOptionsDefaultLabel={selectOptionsDefaultLabel}
tagOptions={tagOptions}
required={required}
readOnly={readOnly}
capitalize={capitalize}
placeholder={loadingMessage && !formData[key]
? loadingMessage
: undefined}
loading={
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)}
type={type}
accessory={aiButtonForField(key)}
/>)}
<div className="flex gap-3">
{/* Fields */}
<div className="space-y-6">
{FORM_METADATA_ENTRIES(
sortTagsObjectWithoutFavs(uniqueTags ?? [])
.map(({ tag, count }) => ({
value: tag,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count, 'tagged'),
})),
aiContent !== undefined,
)
.map(([key, {
label,
note,
required,
selectOptions,
selectOptionsDefaultLabel,
tagOptions,
readOnly,
validate,
validateStringMaxLength,
capitalize,
hideIfEmpty,
shouldHide,
loadingMessage,
type,
}]) =>
(
(!hideIfEmpty || formData[key]) &&
!shouldHide?.(formData)
) &&
<FieldSetWithStatus
key={key}
id={key}
label={label}
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}
isModified={changedFormKeys.includes(key)}
onChange={value => {
const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated);
if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) });
} else if (validateStringMaxLength !== undefined) {
setFormErrors({
...formErrors,
[key]: value.length > validateStringMaxLength
? `${validateStringMaxLength} characters or less`
: undefined,
});
}
if (key === 'title') {
onTitleChange?.(value.trim());
}
}}
selectOptions={selectOptions}
selectOptionsDefaultLabel={selectOptionsDefaultLabel}
tagOptions={tagOptions}
required={required}
readOnly={readOnly}
capitalize={capitalize}
placeholder={loadingMessage && !formData[key]
? loadingMessage
: undefined}
loading={
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)}
type={type}
accessory={aiButtonForField(key)}
/>)}
</div>
{/* Actions */}
<div className={clsx(
'flex gap-3 sticky bottom-0',
'pb-4 md:pb-8 mt-12',
)}>
<Link
className="button"
href={type === 'edit' ? PATH_ADMIN_PHOTOS : PATH_ADMIN_UPLOADS}
@ -360,11 +364,19 @@ export default function PhotoForm({
Cancel
</Link>
<SubmitButtonWithStatus
disabled={!isFormValid(formData) || aiContent?.isLoading}
disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange}
primary
>
{type === 'create' ? 'Create' : 'Update'}
</SubmitButtonWithStatus>
<div className={clsx(
'absolute -top-16 -left-2 right-0 bottom-0 -z-10',
'pointer-events-none',
'bg-gradient-to-t',
'from-white/90 from-60%',
'dark:from-black/90 dark:from-50%',
)} />
</div>
</form>
</div>

View File

@ -3,6 +3,8 @@ import { Photo, PhotoDbInsert, PhotoExif } from '..';
import {
convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString,
generateLocalNaivePostgresString,
generateLocalPostgresString,
} from '@/utility/date';
import { getAspectRatioFromExif, getOffsetFromExif } from '@/utility/exif';
import { toFixedNumber } from '@/utility/number';
@ -284,5 +286,25 @@ export const convertFormDataToPhotoDbInsert = (
? parseFloat(photoForm.priorityOrder)
: undefined,
hidden: photoForm.hidden === 'true',
...generateTakenAtFields(photoForm),
};
};
export const getChangedFormFields = (
original: Partial<PhotoFormData>,
current: Partial<PhotoFormData>,
) => {
return Object
.keys(current)
.filter(key =>
(original[key as keyof PhotoFormData] ?? '') !==
(current[key as keyof PhotoFormData] ?? '')
) as (keyof PhotoFormData)[];
};
export const generateTakenAtFields = (
form?: Partial<PhotoFormData>
): { takenAt: string, takenAtNaive: string } => ({
takenAt: form?.takenAt || generateLocalPostgresString(),
takenAtNaive: form?.takenAtNaive || generateLocalNaivePostgresString(),
});

View File

@ -12,7 +12,8 @@ import { PhotoFormData } from './form';
import { FilmSimulation } from '@/simulation';
export const extractExifDataFromBlobPath = async (
blobPath: string
blobPath: string,
includeInitialPhotoFields?: boolean,
): Promise<{
blobId?: string
photoFormExif?: Partial<PhotoFormData>
@ -55,9 +56,13 @@ export const extractExifDataFromBlobPath = async (
blobId,
...exifData && {
photoFormExif: {
...includeInitialPhotoFields && {
hidden: 'false',
favorite: 'false',
extension,
url,
},
...convertExifToFormData(exifData, filmSimulation),
extension,
url,
},
},
};

View File

@ -96,7 +96,9 @@
@apply
text-invert
bg-gray-900 dark:bg-gray-100
disabled:bg-gray-900 disabled:dark:bg-gray-100
disabled:text-gray-300 disabled:dark:text-gray-700
disabled:bg-white disabled:dark:bg-black
disabled:border-gray-200 disabled:dark:border-gray-700
border-gray-900 dark:border-gray-100
active:bg-gray-700 active:border-gray-700
active:dark:bg-gray-300 active:dark:border-gray-300
@ -123,7 +125,7 @@
}
.text-invert {
@apply
text-gray-100 dark:text-gray-900
text-white dark:text-black
}
.text-medium {
@apply

View File

@ -0,0 +1,30 @@
import { useEffect } from 'react';
export default function usePreventNavigation(
enabled?: boolean,
// eslint-disable-next-line max-len
confirmation = 'Are you sure you want to leave this page? Any unsaved changes will be lost.',
includeButtons?: boolean,
) {
useEffect(() => {
const callback = (e: MouseEvent) => {
const target = e.target as HTMLElement | undefined;
const parent = target?.parentElement as HTMLElement | undefined;
const grandParent = parent?.parentElement as HTMLElement | undefined;
const targets = [target, parent, grandParent];
if (
targets.some(target => target?.tagName === 'A') && (
!includeButtons ||
targets.some(target => target?.tagName === 'BUTTON')
)
) {
if (enabled && !confirm(confirmation)) {
e.stopPropagation();
e.preventDefault();
}
}
};
document.addEventListener('click', callback, true);
return () => document.removeEventListener('click', callback, true);
}, [enabled, confirmation, includeButtons]);
}