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 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) - 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_) - Setup usage limits to avoid unexpected charges (_recommended_)
2. Add rate limiting (_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 - 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`. > 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? #### 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? #### 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`). > 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, baseDirectory: __dirname,
}); });
const eslintConfig = [ const eslintConfig = [{
...compat.extends('next/core-web-vitals', 'next/typescript'), { ignores: [
'.*',
'node_modules',
'next-env.d.ts',
],
},
...compat.extends('next/core-web-vitals', 'next/typescript'), {
plugins: { plugins: {
'@stylistic': stylistic, '@stylistic': stylistic,
}, },
@ -49,6 +55,6 @@ const eslintConfig = [
{ 'code': 80 }, { 'code': 80 },
], ],
}, },
}]; }];
export default eslintConfig; export default eslintConfig;

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ function AdminChildPage({
return ( return (
<AppGrid <AppGrid
contentMain={ contentMain={
<div className="space-y-6"> <div className="space-y-5">
{(backPath || breadcrumb || accessory) && {(backPath || breadcrumb || accessory) &&
<div className={clsx( <div className={clsx(
'flex items-center gap-x-2 gap-y-3', '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 ResponsiveText from './primitives/ResponsiveText';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import ScoreCard from './ScoreCard'; import ScoreCard from './ScoreCard';
import useVisible from '@/utility/useVisible'; import useVisibility from '@/utility/useVisibility';
export default function ChecklistGroup({ export default function ChecklistGroup({
title, title,
@ -28,7 +28,7 @@ export default function ChecklistGroup({
const slug = parameterize(title); const slug = parameterize(title);
useVisible({ ref, onVisible: () => { useVisibility({ ref, onVisible: () => {
if (updateHashOnVisible) { if (updateHashOnVisible) {
window.history.replaceState(null, '', `#${slug}`); window.history.replaceState(null, '', `#${slug}`);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
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-sdk/rsc';
import { AiImageQuery } from '.'; import { AiImageQuery } from '.';
export default function useAiImageQuery( export default function useAiImageQuery(

View File

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

View File

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

View File

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

View File

@ -4,22 +4,29 @@ import { useCallback, useEffect, useState } from 'react';
export default function useHash() { export default function useHash() {
const [hash, setHash] = useState(''); const [hash, setHash] = useState('');
const updateHash = useCallback(() => { const storeHash = useCallback(() => {
setHash(window.location.hash); setHash(window.location.hash.replace('#', ''));
}, []); }, []);
useEffect(() => { useEffect(() => {
window.addEventListener('hashchange', updateHash); window.addEventListener('hashchange', storeHash);
return () => { return () => {
window.removeEventListener('hashchange', updateHash); window.removeEventListener('hashchange', storeHash);
}; };
}, [updateHash]); }, [storeHash]);
// Needed to capture non-request-initiated hash changes // Needed to capture non-request-initiated hash changes
const params = useSearchParams(); const params = useSearchParams();
useEffect(() => { useEffect(() => {
updateHash(); storeHash();
}, [params, updateHash]); }, [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'; import { useEffect } from 'react';
export default function useVisible({ export default function useVisibility({
ref, ref,
onVisible, onVisible,
onHidden, onHidden,

View File

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