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
|
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`).
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
27
package.json
27
package.json
@ -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
1130
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,4 +2,4 @@ module.exports = {
|
|||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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 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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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());
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export default function useVisible({
|
export default function useVisibility({
|
||||||
ref,
|
ref,
|
||||||
onVisible,
|
onVisible,
|
||||||
onHidden,
|
onHidden,
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user