Break up photo form into sections (#298)
This commit is contained in:
parent
a85d5091a8
commit
25b8d65030
@ -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`).
|
||||
|
||||
@ -10,45 +10,51 @@ const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'), {
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@stylistic/indent': ['warn', 2],
|
||||
'no-unused-expressions': ['warn'],
|
||||
'no-duplicate-imports': ['warn'],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_',
|
||||
},
|
||||
],
|
||||
'comma-dangle': [
|
||||
'warn',
|
||||
'always-multiline',
|
||||
],
|
||||
'linebreak-style': [
|
||||
'warn',
|
||||
'unix',
|
||||
],
|
||||
'quotes': [
|
||||
'warn',
|
||||
'single',
|
||||
],
|
||||
'semi': [
|
||||
'warn',
|
||||
'always',
|
||||
],
|
||||
'max-len': [
|
||||
'warn',
|
||||
{ 'code': 80 },
|
||||
],
|
||||
},
|
||||
}];
|
||||
const eslintConfig = [{
|
||||
ignores: [
|
||||
'.*',
|
||||
'node_modules',
|
||||
'next-env.d.ts',
|
||||
],
|
||||
},
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'), {
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@stylistic/indent': ['warn', 2],
|
||||
'no-unused-expressions': ['warn'],
|
||||
'no-duplicate-imports': ['warn'],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn', {
|
||||
'argsIgnorePattern': '^_',
|
||||
'varsIgnorePattern': '^_',
|
||||
},
|
||||
],
|
||||
'comma-dangle': [
|
||||
'warn',
|
||||
'always-multiline',
|
||||
],
|
||||
'linebreak-style': [
|
||||
'warn',
|
||||
'unix',
|
||||
],
|
||||
'quotes': [
|
||||
'warn',
|
||||
'single',
|
||||
],
|
||||
'semi': [
|
||||
'warn',
|
||||
'always',
|
||||
],
|
||||
'max-len': [
|
||||
'warn',
|
||||
{ 'code': 80 },
|
||||
],
|
||||
},
|
||||
}];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
27
package.json
27
package.json
@ -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
1130
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,4 +2,4 @@ module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
105
src/components/AnchorSections.tsx
Normal file
105
src/components/AnchorSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
}));
|
||||
}, []);
|
||||
|
||||
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={thumbnailDimensions.width}
|
||||
height={thumbnailDimensions.height}
|
||||
priority
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-[38rem] relative">
|
||||
<div className="space-y-4 max-w-[38rem] relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<ImageWithFallback
|
||||
alt="Upload"
|
||||
src={url}
|
||||
className={clsx(
|
||||
'border rounded-md overflow-hidden',
|
||||
'border-gray-200 dark:border-gray-700',
|
||||
)}
|
||||
blurDataURL={formData.blurData}
|
||||
blurCompatibilityLevel="none"
|
||||
width={width}
|
||||
height={height}
|
||||
priority
|
||||
/>
|
||||
{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,151 +408,151 @@ 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, {
|
||||
label,
|
||||
note,
|
||||
noteShort,
|
||||
required,
|
||||
selectOptions,
|
||||
selectOptionsDefaultLabel,
|
||||
tagOptions,
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
readOnly,
|
||||
hideModificationStatus,
|
||||
validate,
|
||||
validateStringMaxLength,
|
||||
spellCheck,
|
||||
capitalize,
|
||||
hideIfEmpty,
|
||||
shouldHide,
|
||||
loadingMessage,
|
||||
type,
|
||||
staticValue,
|
||||
}]) => {
|
||||
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
|
||||
const fieldProps: ComponentProps<typeof FieldsetWithStatus> = {
|
||||
id: key,
|
||||
label: label + (
|
||||
key === 'blurData' && shouldDebugImageFallbacks
|
||||
? ` (${(formData[key] ?? '').length} chars.)`
|
||||
: ''
|
||||
),
|
||||
<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,
|
||||
error: formErrors[key],
|
||||
value: staticValue ?? formData[key] ?? '',
|
||||
isModified: (
|
||||
!hideModificationStatus &&
|
||||
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());
|
||||
}
|
||||
},
|
||||
required,
|
||||
selectOptions,
|
||||
selectOptionsDefaultLabel: selectOptionsDefaultLabel,
|
||||
selectOptionsDefaultLabel,
|
||||
tagOptions,
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
required,
|
||||
readOnly,
|
||||
hideModificationStatus,
|
||||
validate,
|
||||
validateStringMaxLength,
|
||||
spellCheck,
|
||||
capitalize,
|
||||
placeholder: loadingMessage && !formData[key]
|
||||
? loadingMessage
|
||||
: undefined,
|
||||
loading: (
|
||||
(loadingMessage && !formData[key] ? true : false) ||
|
||||
isFieldGeneratingAi(key)
|
||||
),
|
||||
hideIfEmpty,
|
||||
shouldHide,
|
||||
loadingMessage,
|
||||
type,
|
||||
accessory: accessoryForField(key),
|
||||
};
|
||||
|
||||
switch (key) {
|
||||
case 'film':
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
tagOptionsDefaultIcon={<span
|
||||
className="w-4 overflow-hidden"
|
||||
>
|
||||
<PhotoFilmIcon />
|
||||
</span>}
|
||||
/>;
|
||||
case 'applyRecipeTitleGlobally':
|
||||
return <ApplyRecipeTitleGloballyCheckbox
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
photoId={initialPhotoForm.id}
|
||||
recipeTitle={formData.recipeTitle}
|
||||
hasRecipeTitleChanged={
|
||||
changedFormKeys.includes('recipeTitle')}
|
||||
recipeData={formData.recipeData}
|
||||
film={formData.film}
|
||||
onMatchResults={onMatchResults}
|
||||
/>;
|
||||
case 'colorData':
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
noteComplex={<PhotoColors
|
||||
className="translate-y-[1.5px]"
|
||||
classNameDot="size-[13px]!"
|
||||
// eslint-disable-next-line max-len
|
||||
colorData={generateColorDataFromString(formData.colorData)}
|
||||
/>}
|
||||
/>;
|
||||
case 'visibility':
|
||||
return <FieldsetVisibility
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
isModified={didVisibilityChange(
|
||||
initialPhotoForm,
|
||||
formData,
|
||||
)}
|
||||
/>;
|
||||
case 'favorite':
|
||||
return <FieldsetFavs
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
default:
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
staticValue,
|
||||
}]) => {
|
||||
if (!isFieldHidden(key, hideIfEmpty, shouldHide)) {
|
||||
// eslint-disable-next-line max-len
|
||||
const fieldProps: ComponentProps<typeof FieldsetWithStatus> = {
|
||||
id: key,
|
||||
label: label + (
|
||||
key === 'blurData' && shouldDebugImageFallbacks
|
||||
? ` (${(formData[key] ?? '').length} chars.)`
|
||||
: ''
|
||||
),
|
||||
note,
|
||||
noteShort,
|
||||
error: formErrors[key],
|
||||
value: staticValue ?? formData[key] ?? '',
|
||||
isModified: (
|
||||
!hideModificationStatus &&
|
||||
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,
|
||||
selectOptionsDefaultLabel: selectOptionsDefaultLabel,
|
||||
tagOptions,
|
||||
tagOptionsLimit,
|
||||
tagOptionsLimitValidationMessage,
|
||||
required,
|
||||
readOnly,
|
||||
spellCheck,
|
||||
capitalize,
|
||||
placeholder: loadingMessage && !formData[key]
|
||||
? loadingMessage
|
||||
: undefined,
|
||||
loading: (
|
||||
(loadingMessage && !formData[key] ? true : false) ||
|
||||
isFieldGeneratingAi(key)
|
||||
),
|
||||
type,
|
||||
accessory: accessoryForField(key),
|
||||
};
|
||||
switch (key) {
|
||||
case 'film':
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
tagOptionsDefaultIcon={<span
|
||||
className="w-4 overflow-hidden"
|
||||
>
|
||||
<PhotoFilmIcon />
|
||||
</span>}
|
||||
/>;
|
||||
case 'applyRecipeTitleGlobally':
|
||||
return <ApplyRecipeTitleGloballyCheckbox
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
photoId={initialPhotoForm.id}
|
||||
recipeTitle={formData.recipeTitle}
|
||||
hasRecipeTitleChanged={
|
||||
changedFormKeys.includes('recipeTitle')}
|
||||
recipeData={formData.recipeData}
|
||||
film={formData.film}
|
||||
onMatchResults={onMatchResults}
|
||||
/>;
|
||||
case 'colorData':
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
noteComplex={<PhotoColors
|
||||
classNameDot="size-[13px]!"
|
||||
// eslint-disable-next-line max-len
|
||||
colorData={generateColorDataFromString(formData.colorData)}
|
||||
/>}
|
||||
/>;
|
||||
case 'visibility':
|
||||
return <FieldsetVisibility
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
isModified={didVisibilityChange(
|
||||
initialPhotoForm,
|
||||
formData,
|
||||
)}
|
||||
/>;
|
||||
case 'favorite':
|
||||
return <FieldsetFavs
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
default:
|
||||
return <FieldsetWithStatus
|
||||
key={key}
|
||||
{...fieldProps}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</>,
|
||||
}))}
|
||||
/>
|
||||
{/* Actions */}
|
||||
<div className={clsx(
|
||||
'flex gap-3 sticky bottom-0',
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
20
src/utility/useIsVisible.ts
Normal file
20
src/utility/useIsVisible.ts
Normal 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;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function useVisible({
|
||||
export default function useVisibility({
|
||||
ref,
|
||||
onVisible,
|
||||
onHidden,
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user