Merge branch 'main' into static
This commit is contained in:
commit
5af688b9bf
20
package.json
20
package.json
@ -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
575
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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); }
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
30
src/utility/usePreventNavigation.ts
Normal file
30
src/utility/usePreventNavigation.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user