Break up photo form into sections (#298)

This commit is contained in:
Sam Becker 2025-08-26 20:36:51 -05:00 committed by GitHub
parent a85d5091a8
commit 25b8d65030
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1123 additions and 854 deletions

View File

@ -75,7 +75,7 @@ _⚠ READ BEFORE PROCEEDING_
1. Setup OpenAI
- If you don't already have one, create an [OpenAI](https://openai.com) account and fund it (see [this thread](https://github.com/sambecker/exif-photo-blog/issues/110) if you're having issues)
- Generate an API key and store in environment variable `OPENAI_SECRET_KEY`
- Generate an API key and store in environment variable `OPENAI_SECRET_KEY` (make sure to enable Responses API write access if customizing permissions)
- Setup usage limits to avoid unexpected charges (_recommended_)
2. Add rate limiting (_recommended_)
- As an additional precaution, create an Upstash Redis store from the storage tab of the Vercel dashboard and link it to your project in order to enable rate limiting—no further configuration necessary
@ -363,7 +363,7 @@ Thank you ❤️ translators: [@sconetto](https://github.com/sconetto) (`pt-br`,
> 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`.
#### I've added my OpenAI key but can't seem to make it work. Why am I seeing connection errors?
> You may need to pre-purchase credits before accessing the OpenAI API. See [#110](https://github.com/sambecker/exif-photo-blog/issues/110) for discussion.
> You may need to pre-purchase credits before accessing the OpenAI API. See [#110](https://github.com/sambecker/exif-photo-blog/issues/110) for discussion. If you've customized key permissions, make sure write access to the Responses API is enabled.
#### How do I generate AI text for preexisting photos?
> Once AI text generation is configured, photos missing text will show up in photo updates (`/admin/photos/updates`).

View File

@ -10,8 +10,14 @@ const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'), {
const eslintConfig = [{
ignores: [
'.*',
'node_modules',
'next-env.d.ts',
],
},
...compat.extends('next/core-web-vitals', 'next/typescript'), {
plugins: {
'@stylistic': stylistic,
},
@ -49,6 +55,6 @@ const eslintConfig = [
{ 'code': 80 },
],
},
}];
}];
export default eslintConfig;

View File

@ -4,14 +4,15 @@
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@ai-sdk/openai": "^1.3.23",
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/s3-request-presigner": "3.864.0",
"@ai-sdk/openai": "^2.0.20",
"@ai-sdk/rsc": "^1.0.23",
"@aws-sdk/client-s3": "3.873.0",
"@aws-sdk/s3-request-presigner": "3.873.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
@ -21,7 +22,7 @@
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^1.1.1",
"@vercel/speed-insights": "^1.2.0",
"ai": "^4.3.19",
"ai": "^5.0.23",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -34,7 +35,7 @@
"fast-deep-equal": "^3.1.3",
"framer-motion": "^12.23.12",
"nanoid": "^5.1.5",
"next": "15.4.6",
"next": "15.5.0",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
@ -51,25 +52,23 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@next/bundle-analyzer": "15.4.6",
"@next/eslint-plugin-next": "^15.4.6",
"@next/bundle-analyzer": "15.5.0",
"@next/eslint-plugin-next": "^15.5.0",
"@stylistic/eslint-plugin": "^5.2.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.12",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/culori": "^4.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.3.0",
"@types/pg": "^8.15.5",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
"eslint": "9.33.0",
"eslint-config-next": "15.4.6",
"eslint": "9.34.0",
"eslint-config-next": "15.5.0",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^30.0.5",
"jest-environment-jsdom": "^30.0.5",

1130
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,4 +2,4 @@ module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}
};

View File

@ -11,7 +11,7 @@ import {
generateLocalPostgresString,
} from '@/utility/date';
import sleep from '@/utility/sleep';
import { readStreamableValue } from 'ai/rsc';
import { readStreamableValue } from '@ai-sdk/rsc';
import { useRouter } from 'next/navigation';
import { Dispatch, SetStateAction, useRef, useState } from 'react';
import { BiCheckCircle } from 'react-icons/bi';

View File

@ -12,7 +12,8 @@ export default function AdminAppConfigurationSidebar({
simplifiedView?: boolean
areInternalToolsEnabled: boolean
}) {
const hash = useHash();
const { hash } = useHash();
return (
<div className={clsx(
'sticky top-0 pt-2.5 -mt-2.5',

View File

@ -26,7 +26,7 @@ function AdminChildPage({
return (
<AppGrid
contentMain={
<div className="space-y-6">
<div className="space-y-5">
{(backPath || breadcrumb || accessory) &&
<div className={clsx(
'flex items-center gap-x-2 gap-y-3',

View File

@ -0,0 +1,105 @@
import useHash from '@/utility/useHash';
import useVisibility from '@/utility/useVisibility';
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import { useDebouncedCallback } from 'use-debounce';
export default function AnchorSections({
sections,
className,
classNameSection,
}: {
sections: {
id: string
content: ReactNode
}[]
className?: string
classNameSection?: string
}) {
const { hash, updateHash } = useHash();
const isAutoSelectDisabled = useRef(false);
const firstSection = useMemo(() => sections[0].id, [sections]);
// Highlight initial section
useEffect(() => {
updateHash(firstSection);
}, [updateHash, firstSection]);
// Disable auto-select for 100ms after hash
useEffect(() => {
isAutoSelectDisabled.current = true;
const timeout = setTimeout(() => {
isAutoSelectDisabled.current = false;
}, 100);
return () => clearTimeout(timeout);
}, [hash]);
// Reset section when scrolled to the top
const _onScroll = useCallback(() => {
if (window.scrollY <= 0) {
console.log('resetting section');
updateHash(firstSection);
}
}, [updateHash, firstSection]);
const onScroll = useDebouncedCallback(_onScroll, 100, { leading: true });
useEffect(() => {
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [onScroll]);
const onVisible = useCallback((section: string) => {
if (!isAutoSelectDisabled.current) {
updateHash(section);
}
}, [updateHash]);
return (
<div className={className}>
{sections.map(({ id, content }) => (
<AnchorSection
key={id}
id={id}
className={classNameSection}
onVisible={onVisible}
>
{content}
</AnchorSection>
))}
</div>
);
}
function AnchorSection({
id,
children,
onVisible: _onVisible,
onHidden: _onHidden,
className,
}: {
id: string
children: ReactNode
onVisible?: (section: string, force?: boolean) => void
onHidden?: (section: string, force?: boolean) => void
className?: string
}) {
const ref = useRef<HTMLDivElement>(null);
const onVisible = useCallback(() => _onVisible?.(id), [id, _onVisible]);
const onHidden = useCallback(() => _onHidden?.(id), [id, _onHidden]);
useVisibility({ ref, onVisible, onHidden });
return (
<div ref={ref} {...{ id, className }}>
<a href={`#${id}`} />
{children}
</div>
);
}

View File

@ -5,7 +5,7 @@ import Badge from './Badge';
import ResponsiveText from './primitives/ResponsiveText';
import { parameterize } from '@/utility/string';
import ScoreCard from './ScoreCard';
import useVisible from '@/utility/useVisible';
import useVisibility from '@/utility/useVisibility';
export default function ChecklistGroup({
title,
@ -28,7 +28,7 @@ export default function ChecklistGroup({
const slug = parameterize(title);
useVisible({ ref, onVisible: () => {
useVisibility({ ref, onVisible: () => {
if (updateHashOnVisible) {
window.history.replaceState(null, '', `#${slug}`);
}

View File

@ -5,9 +5,10 @@ import { BLUR_ENABLED } from '@/app/config';
import { useAppState } from '@/app/AppState';
import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
export default function ImageWithFallback({
ref: refProp,
className,
classNameImage = 'object-cover h-full',
blurDataURL,
@ -15,6 +16,7 @@ export default function ImageWithFallback({
priority,
...props
}: ImageProps & {
ref?: RefObject<HTMLImageElement | null>
blurCompatibilityLevel?: 'none' | 'low' | 'high'
classNameImage?: string
}) {
@ -56,7 +58,7 @@ export default function ImageWithFallback({
className,
)}
>
<Image ref={ref} {...{
<Image ref={refProp ?? ref} {...{
...props,
priority,
className: classNameImage,

View File

@ -3,7 +3,7 @@
import { ComponentProps, useRef } from 'react';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import useVisible from '@/utility/useVisible';
import useVisibility from '@/utility/useVisibility';
import OGLoaderImage from './OGLoaderImage';
export type OGTilePropsCore = Omit<
@ -26,7 +26,7 @@ export default function OGTile({
} & ComponentProps<typeof OGLoaderImage>) {
const ref = useRef<HTMLAnchorElement>(null);
useVisible({ ref, onVisible });
useVisibility({ ref, onVisible });
return (
<Link

View File

@ -15,7 +15,7 @@ import { Photo } from '.';
import { PhotoSetCategory } from '../category';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/app/AppState';
import useVisible from '@/utility/useVisible';
import useVisibility from '@/utility/useVisibility';
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
import { SortBy } from './sort';
import { SWR_KEYS } from '@/swr';
@ -150,7 +150,7 @@ export default function InfinitePhotoScroll({
},
} as any), [data, mutate]);
useVisible({ ref: buttonContainerRef, onVisible: () => {
useVisibility({ ref: buttonContainerRef, onVisible: () => {
if (ADMIN_DB_OPTIMIZE_ENABLED && size === 0) {
advance();
}

View File

@ -3,7 +3,10 @@
import AdminChildPage from '@/components/AdminChildPage';
import { Photo } from '.';
import { PATH_ADMIN_PHOTOS } from '@/app/path';
import { PhotoFormData, convertPhotoToFormData } from './form';
import {
PhotoFormData,
convertPhotoToFormData,
} from './form';
import PhotoForm from './form/PhotoForm';
import { Tags } from '@/tag';
import AiButton from './ai/AiButton';
@ -80,8 +83,8 @@ export default function PhotoEditPageClient({
uniqueFilms={uniqueFilms}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}
onFormDataChange={setShouldConfirmAiTextGeneration}
onFormStatusChange={setIsPending}
onFormDataChange={setShouldConfirmAiTextGeneration}
/>
</AdminChildPage>
);

View File

@ -35,7 +35,7 @@ import {
import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useCallback, useMemo, useRef } from 'react';
import useVisible from '@/utility/useVisible';
import useVisibility from '@/utility/useVisibility';
import PhotoDate from './PhotoDate';
import { useAppState } from '@/app/AppState';
import { LuExpand } from 'react-icons/lu';
@ -171,7 +171,7 @@ export default function PhotoLarge({
const showRecipeContent = showRecipe && shouldShowRecipeDataForPhoto(photo);
const showFilmContent = showFilm && shouldShowFilmDataForPhoto(photo);
useVisible({ ref, onVisible });
useVisibility({ ref, onVisible });
const hasTitle =
showTitle &&

View File

@ -11,7 +11,7 @@ import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/app/path';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/app/config';
import { useRef } from 'react';
import useVisible from '@/utility/useVisible';
import useVisibility from '@/utility/useVisibility';
import LinkWithStatus from '@/components/LinkWithStatus';
import Spinner from '@/components/Spinner';
import PhotoColors from './color/PhotoColors';
@ -36,7 +36,7 @@ export default function PhotoMedium({
} & PhotoSetCategory) {
const ref = useRef<HTMLAnchorElement>(null);
useVisible({ ref, onVisible });
useVisibility({ ref, onVisible });
return (
<LinkWithStatus

View File

@ -10,7 +10,7 @@ import { clsx } from 'clsx/lite';
import { pathForPhoto } from '@/app/path';
import { SHOULD_PREFETCH_ALL_LINKS } from '@/app/config';
import { useRef } from 'react';
import useVisible from '@/utility/useVisible';
import useVisibility from '@/utility/useVisibility';
export default function PhotoSmall({
photo,
@ -28,7 +28,7 @@ export default function PhotoSmall({
} & PhotoSetCategory) {
const ref = useRef<HTMLAnchorElement>(null);
useVisible({ ref, onVisible });
useVisibility({ ref, onVisible });
return (
<Link

View File

@ -2,7 +2,10 @@
import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_UPLOADS } from '@/app/path';
import { PhotoFormData, generateTakenAtFields } from './form';
import {
PhotoFormData,
generateTakenAtFields,
} from './form';
import PhotoForm from './form/PhotoForm';
import { Tags } from '@/tag';
import usePhotoFormParent from './form/usePhotoFormParent';

View File

@ -57,7 +57,7 @@ import {
BLUR_ENABLED,
} from '@/app/config';
import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from 'ai/rsc';
import { createStreamableValue } from '@ai-sdk/rsc';
import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';

View File

@ -8,12 +8,12 @@ export default function AiButton({
aiContent,
requestFields = AI_AUTO_GENERATED_FIELDS_ALL,
shouldConfirm,
onClick,
...props
}: {
aiContent: AiContent
requestFields?: AiAutoGeneratedField[]
shouldConfirm?: boolean
className?: string
} & ComponentProps<typeof LoaderButton>) {
const isLoading = useMemo(() =>
(requestFields ?? []).map(field => {
@ -46,6 +46,7 @@ export default function AiButton({
!shouldConfirm ||
confirm('Are you sure you want to overwrite existing content?')
) {
onClick?.(e);
aiContent.request(requestFields);
} else {
e.preventDefault();

View File

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { streamAiImageQueryAction } from '../actions';
import { readStreamableValue } from 'ai/rsc';
import { readStreamableValue } from '@ai-sdk/rsc';
import { AiImageQuery } from '.';
export default function useAiImageQuery(

View File

@ -5,11 +5,13 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
FIELDS_WITH_JSON,
FORM_METADATA_ENTRIES,
FORM_METADATA_ENTRIES_BY_SECTION,
FORM_SECTIONS,
FormFields,
FormMeta,
PhotoFormData,
@ -50,6 +52,10 @@ import { didVisibilityChange } from '../visibility';
import FieldsetVisibility from '../visibility/FieldsetVisibility';
import PhotoColors from '../color/PhotoColors';
import { generateColorDataFromString } from '../color/client';
import { capitalize } from '@/utility/string';
import AnchorSections from '@/components/AnchorSections';
import useIsVisible from '@/utility/useIsVisible';
import useHash from '@/utility/useHash';
const THUMBNAIL_SIZE = 300;
@ -86,6 +92,8 @@ export default function PhotoForm({
useState(getFormErrors(initialPhotoForm));
const [formActionErrorMessage, setFormActionErrorMessage] = useState('');
const { hash } = useHash();
const { invalidateSwr, shouldDebugImageFallbacks } = useAppState();
const appText = useAppText();
@ -145,11 +153,6 @@ export default function PhotoForm({
}
}, [updatedExifData]);
const {
width,
height,
} = getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio);
const url = formData.url ?? '';
useEffect(() => {
@ -283,23 +286,56 @@ export default function PhotoForm({
}));
}, []);
return (
<div className="space-y-8 max-w-[38rem] relative">
<div className="flex gap-2">
<div className="relative">
const formContent = useMemo(() =>
FORM_METADATA_ENTRIES_BY_SECTION(
convertTagsForForm(uniqueTags, appText),
convertRecipesForForm(uniqueRecipes),
convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)),
aiContent !== undefined,
shouldStripGpsData,
), [
uniqueTags,
appText,
uniqueRecipes,
uniqueFilms,
formData.make,
aiContent,
shouldStripGpsData,
]);
const ref = useRef<HTMLImageElement>(null);
const isThumbnailVisible = useIsVisible({ ref, initiallyVisible: true });
const thumbnailDimensions =
getDimensionsFromSize(THUMBNAIL_SIZE, formData.aspectRatio);
const thumbnail = (includeRef?: boolean, className?: string) =>
<ImageWithFallback
ref={includeRef ? ref : undefined}
alt="Upload"
src={url}
className={clsx(
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700',
className,
)}
blurDataURL={formData.blurData}
blurCompatibilityLevel="none"
width={width}
height={height}
width={thumbnailDimensions.width}
height={thumbnailDimensions.height}
priority
/>
/>;
return (
<div className="space-y-4 max-w-[38rem] relative">
<div className="flex gap-2">
<div className="relative">
{thumbnail(true)}
<div className="max-lg:hidden fixed top-8 left-[42rem]">
{thumbnail(false, clsx(
'opacity-0 -translate-y-4',
!isThumbnailVisible &&
'opacity-100 translate-y-0 transition-all duration-200',
))}
</div>
<div className={clsx(
'absolute top-2 left-2 transition-opacity duration-500',
aiContent?.isLoading ? 'opacity-100' : 'opacity-0',
@ -327,6 +363,34 @@ export default function PhotoForm({
</div>
{formActionErrorMessage &&
<ErrorNote>{formActionErrorMessage}</ErrorNote>}
<div className={clsx(
'flex gap-4',
'sticky top-0 z-10 bg-main',
'border-b border-gray-200 dark:border-gray-700',
'uppercase tracking-wide text-sm',
'*:py-2',
)}>
<span className="flex gap-4 max-sm:hidden">
<span>Photo Details</span>
<span className="text-extra-extra-dim">/</span>
</span>
{FORM_SECTIONS.map(section => (
<a
key={section}
href={`#${section}`}
className={clsx(
'cursor-pointer hover:text-main',
'active:border-b-2',
'active:border-b-gray-200 dark:active:border-b-gray-700',
section === hash
? 'font-bold border-b-2 border-b-black dark:border-b-white'
: 'text-dim',
)}
>
{capitalize(section)}
</a>
))}
</div>
<form
action={data => (type === 'create'
? createPhotoAction
@ -344,15 +408,14 @@ export default function PhotoForm({
}}
>
{/* Fields */}
<div className="space-y-6">
{FORM_METADATA_ENTRIES(
convertTagsForForm(uniqueTags, appText),
convertRecipesForForm(uniqueRecipes),
convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)),
aiContent !== undefined,
shouldStripGpsData,
)
.map(([key, {
<AnchorSections
className="mt-6 space-y-5 *:space-y-5"
classNameSection="scroll-mt-12"
sections={formContent
.map(({ section, fields }) => ({
id: section,
content: <>
{fields.map(([key, {
label,
note,
noteShort,
@ -375,6 +438,7 @@ export default function PhotoForm({
staticValue,
}]) => {
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
// eslint-disable-next-line max-len
const fieldProps: ComponentProps<typeof FieldsetWithStatus> = {
id: key,
label: label + (
@ -429,7 +493,6 @@ export default function PhotoForm({
type,
accessory: accessoryForField(key),
};
switch (key) {
case 'film':
return <FieldsetWithStatus
@ -458,7 +521,6 @@ export default function PhotoForm({
key={key}
{...fieldProps}
noteComplex={<PhotoColors
className="translate-y-[1.5px]"
classNameDot="size-[13px]!"
// eslint-disable-next-line max-len
colorData={generateColorDataFromString(formData.colorData)}
@ -488,7 +550,9 @@ export default function PhotoForm({
}
}
})}
</div>
</>,
}))}
/>
{/* Actions */}
<div className={clsx(
'flex gap-3 sticky bottom-0',

View File

@ -43,6 +43,7 @@ export type AnnotatedTag = {
};
export type FormMeta = {
section: string
label: string
note?: string
noteShort?: string
@ -82,12 +83,14 @@ const FORM_METADATA = (
shouldStripGpsData?: boolean,
): Record<keyof PhotoFormData, FormMeta> => ({
title: {
section: 'text',
label: 'title',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
shouldNotOverwriteWithNullDataOnSync: true,
},
caption: {
section: 'text',
label: 'caption',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
@ -95,28 +98,51 @@ const FORM_METADATA = (
!aiTextGeneration && (!title && !caption),
},
tags: {
section: 'text',
label: 'tags',
tagOptions,
validate: getValidationMessageForTags,
},
semanticDescription: {
section: 'text',
type: 'textarea',
label: 'semantic description (not visible)',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
shouldHide: () => !aiTextGeneration,
},
id: { label: 'id', readOnly: true, hideIfEmpty: true },
blurData: {
label: 'blur data',
readOnly: true,
visibility: {
section: 'text',
type: 'text',
label: 'visibility',
excludeFromInsert: true,
},
excludeFromFeeds: {
section: 'text',
label: 'exclude from feeds',
type: 'hidden',
},
hidden: {
section: 'text',
label: 'hidden',
type: 'hidden',
},
favorite: {
section: 'text',
label: 'favorite',
type: 'checkbox',
excludeFromInsert: true,
},
make: {
section: 'exif',
label: 'camera make',
},
model: {
section: 'exif',
label: 'camera model',
},
url: { label: 'storage url', readOnly: true },
extension: { label: 'extension', readOnly: true },
aspectRatio: { label: 'aspect ratio', readOnly: true },
make: { label: 'camera make' },
model: { label: 'camera model' },
film: {
section: 'exif',
label: 'film',
note: 'Intended for Fujifilm cameras and analog scans',
noteShort: 'Fujifilm cameras / analog scans',
@ -125,6 +151,7 @@ const FORM_METADATA = (
shouldNotOverwriteWithNullDataOnSync: true,
},
recipeTitle: {
section: 'exif',
label: 'recipe title',
tagOptions: recipeOptions,
tagOptionsLimit: 1,
@ -133,6 +160,7 @@ const FORM_METADATA = (
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
},
applyRecipeTitleGlobally: {
section: 'exif',
label: 'apply recipe title globally',
type: 'checkbox',
excludeFromInsert: true,
@ -146,6 +174,7 @@ const FORM_METADATA = (
),
},
recipeData: {
section: 'exif',
type: 'textarea',
label: 'recipe data',
spellCheck: false,
@ -165,45 +194,81 @@ const FORM_METADATA = (
return validationMessage;
},
},
focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
lensMake: { label: 'lens make' },
lensModel: { label: 'lens model' },
fNumber: { label: 'aperture' },
iso: { label: 'ISO' },
exposureTime: { label: 'exposure time' },
exposureCompensation: { label: 'exposure compensation' },
locationName: { label: 'location name', shouldHide: () => true },
latitude: { label: 'latitude' },
longitude: { label: 'longitude' },
focalLength: {
section: 'exif',
label: 'focal length',
},
focalLengthIn35MmFormat: {
section: 'exif',
label: 'focal length 35mm-equivalent',
},
lensMake: { section: 'exif', label: 'lens make' },
lensModel: { section: 'exif', label: 'lens model' },
fNumber: { section: 'exif', label: 'aperture' },
iso: { section: 'exif', label: 'ISO' },
exposureTime: { section: 'exif', label: 'exposure time' },
exposureCompensation: { section: 'exif', label: 'exposure compensation' },
locationName: {
section: 'exif',
label: 'location name',
shouldHide: () => true,
},
latitude: { section: 'exif', label: 'latitude' },
longitude: { section: 'exif', label: 'longitude' },
takenAt: {
section: 'exif',
label: 'taken at',
validate: validationMessagePostgresDateString,
},
takenAtNaive: {
section: 'exif',
label: 'taken at (naive)',
validate: validationMessageNaivePostgresDateString,
},
id: {
section: 'storage',
label: 'id',
readOnly: true,
hideIfEmpty: true,
},
url: {
section: 'storage',
label: 'storage url',
readOnly: true,
},
extension: {
section: 'storage',
label: 'extension',
readOnly: true,
},
blurData: {
section: 'storage',
label: 'blur data',
readOnly: true,
},
aspectRatio: {
section: 'storage',
label: 'aspect ratio',
readOnly: true,
},
priorityOrder: {
section: 'misc',
label: 'priority order',
},
colorData: {
section: 'misc',
type: 'textarea',
label: 'color data',
isJson: true,
shouldHide: () => !COLOR_SORT_ENABLED,
},
colorSort: {
section: 'misc',
label: 'color sort',
shouldHide: () => !COLOR_SORT_ENABLED,
},
priorityOrder: { label: 'priority order' },
excludeFromFeeds: { label: 'exclude from feeds', type: 'hidden' },
hidden: { label: 'hidden', type: 'hidden' },
visibility: {
type: 'text',
label: 'visibility',
excludeFromInsert: true,
},
favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true },
shouldStripGpsData: {
section: 'misc',
label: 'strip gps data',
type: 'hidden',
excludeFromInsert: true,
@ -225,6 +290,28 @@ export const FORM_METADATA_ENTRIES = (
) =>
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
export const FORM_METADATA_ENTRIES_BY_SECTION = (
...args: Parameters<typeof FORM_METADATA>
) => {
const fields = (Object
.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
return fields.reduce((acc, field) => {
const section = acc.find(s => s.section === field[1].section);
if (section) {
section.fields.push(field);
} else {
acc.push({ section: field[1].section, fields: [field] });
}
return acc;
}, [] as {
section: string
fields: [keyof PhotoFormData, FormMeta][]
}[]);
};
export const FORM_SECTIONS = FORM_METADATA_ENTRIES_BY_SECTION()
.map(section => section.section);
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
keys.map(key => FORM_METADATA()[key].label.toUpperCase());

View File

@ -1,5 +1,5 @@
import { generateText, streamText } from 'ai';
import { createStreamableValue } from 'ai/rsc';
import { createStreamableValue } from '@ai-sdk/rsc';
import { createOpenAI } from '@ai-sdk/openai';
import { Redis } from '@upstash/redis';
import { Ratelimit } from '@upstash/ratelimit';

View File

@ -4,22 +4,29 @@ import { useCallback, useEffect, useState } from 'react';
export default function useHash() {
const [hash, setHash] = useState('');
const updateHash = useCallback(() => {
setHash(window.location.hash);
const storeHash = useCallback(() => {
setHash(window.location.hash.replace('#', ''));
}, []);
useEffect(() => {
window.addEventListener('hashchange', updateHash);
window.addEventListener('hashchange', storeHash);
return () => {
window.removeEventListener('hashchange', updateHash);
window.removeEventListener('hashchange', storeHash);
};
}, [updateHash]);
}, [storeHash]);
// Needed to capture non-request-initiated hash changes
const params = useSearchParams();
useEffect(() => {
updateHash();
}, [params, updateHash]);
storeHash();
}, [params, storeHash]);
return hash.replace('#', '');
const updateWindowHash = useCallback((hash: string) => {
window.history.replaceState(null, '', `#${hash}`);
}, []);
return {
hash,
updateHash: updateWindowHash,
};
}

View File

@ -0,0 +1,20 @@
import { RefObject, useState } from 'react';
import useVisibility from './useVisibility';
export default function useIsVisible({
ref,
initiallyVisible = false,
}: {
ref: RefObject<HTMLElement | null>
initiallyVisible?: boolean
}) {
const [isVisible, setIsVisible] = useState(initiallyVisible);
useVisibility({
ref,
onVisible: () => setIsVisible(true),
onHidden: () => setIsVisible(false),
});
return isVisible;
}

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
export default function useVisible({
export default function useVisibility({
ref,
onVisible,
onHidden,

View File

@ -242,6 +242,7 @@ html {
bg-main
border-gray-200 dark:border-gray-700
font-mono text-base leading-tight
placeholder:text-extra-dim
}
input[type=text], input[type=email], input[type=password], select, textarea {
@apply