commit
b78a653b4b
@ -22,6 +22,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
return {
|
return {
|
||||||
maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
|
maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
|
||||||
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
|
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
|
||||||
|
addRandomSuffix: true,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid upload');
|
throw new Error('Invalid upload');
|
||||||
|
|||||||
36
package.json
36
package.json
@ -9,28 +9,28 @@
|
|||||||
"analyze": "ANALYZE=true next build"
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^1.3.9",
|
"@ai-sdk/openai": "^1.3.10",
|
||||||
"@aws-sdk/client-s3": "3.782.0",
|
"@aws-sdk/client-s3": "3.787.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.782.0",
|
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.2",
|
"@radix-ui/react-visually-hidden": "^1.1.3",
|
||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.34.7",
|
"@upstash/redis": "^1.34.7",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@vercel/blob": "^0.27.3",
|
"@vercel/blob": "^1.0.0",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@vercel/speed-insights": "^1.2.0",
|
||||||
"ai": "^4.3.4",
|
"ai": "^4.3.5",
|
||||||
"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",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"framer-motion": "^12.6.3",
|
"framer-motion": "^12.6.5",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "15.2.4",
|
"next": "15.3.0",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
@ -47,8 +47,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@next/bundle-analyzer": "15.2.4",
|
"@next/bundle-analyzer": "15.3.0",
|
||||||
"@next/eslint-plugin-next": "^15.2.4",
|
"@next/eslint-plugin-next": "^15.3.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.3",
|
"@tailwindcss/postcss": "^4.1.3",
|
||||||
@ -56,14 +56,14 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.1",
|
||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.12",
|
||||||
"@types/react": "19.1.0",
|
"@types/react": "19.1.1",
|
||||||
"@types/react-dom": "19.1.1",
|
"@types/react-dom": "19.1.2",
|
||||||
"@types/sanitize-html": "^2.15.0",
|
"@types/sanitize-html": "^2.15.0",
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
"eslint": "9.24.0",
|
"eslint": "9.24.0",
|
||||||
"eslint-config-next": "15.2.4",
|
"eslint-config-next": "15.3.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
|||||||
1268
pnpm-lock.yaml
generated
1268
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,28 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { ComponentProps, ReactNode, useState } from 'react';
|
||||||
ComponentProps,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import LinkWithStatusChild from './primitives/LinkWithStatusChild';
|
||||||
import clsx from 'clsx/lite';
|
import clsx from 'clsx/lite';
|
||||||
|
|
||||||
// Avoid showing spinner for too short a time
|
|
||||||
const FLICKER_THRESHOLD = 400;
|
|
||||||
// Clear loading status after long duration
|
|
||||||
const MAX_LOADING_DURATION = 15_000;
|
|
||||||
|
|
||||||
export default function LinkWithStatus({
|
export default function LinkWithStatus({
|
||||||
loadingClassName,
|
|
||||||
href,
|
|
||||||
className,
|
|
||||||
onClick,
|
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
|
loadingClassName,
|
||||||
isLoading: isLoadingProp = false,
|
isLoading: isLoadingProp = false,
|
||||||
setIsLoading: setIsLoadingProp,
|
setIsLoading: setIsLoadingProp,
|
||||||
...props
|
...props
|
||||||
@ -33,62 +19,14 @@ export default function LinkWithStatus({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
setIsLoading?: (isLoading: boolean) => void
|
setIsLoading?: (isLoading: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const path = usePathname();
|
|
||||||
|
|
||||||
const [pathWhenClicked, setPathWhenClicked] = useState<string>();
|
|
||||||
const [_isLoading, _setIsLoading] = useState(false);
|
const [_isLoading, _setIsLoading] = useState(false);
|
||||||
const isLoading = isLoadingProp || _isLoading;
|
const isLoading = isLoadingProp || _isLoading;
|
||||||
const setIsLoading = setIsLoadingProp || _setIsLoading;
|
const setIsLoading = setIsLoadingProp || _setIsLoading;
|
||||||
|
|
||||||
const isLoadingStartTime = useRef<number | undefined>(undefined);
|
|
||||||
|
|
||||||
const startLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
||||||
const stopLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
||||||
const maxLoadingTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
||||||
|
|
||||||
const isControlled = typeof children === 'function';
|
const isControlled = typeof children === 'function';
|
||||||
|
|
||||||
const clearTimeouts = useCallback(() => {
|
|
||||||
[startLoadingTimeout, stopLoadingTimeout, maxLoadingTimeout]
|
|
||||||
.forEach(timeout => {
|
|
||||||
if (timeout.current) { clearTimeout(timeout.current); }
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopLoading = useCallback(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setPathWhenClicked(undefined);
|
|
||||||
}, [setIsLoading]);
|
|
||||||
|
|
||||||
const isVisitingLinkHref = path === href;
|
|
||||||
|
|
||||||
const shouldCancelLoading =
|
|
||||||
(pathWhenClicked && pathWhenClicked !== path) ||
|
|
||||||
isVisitingLinkHref;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldCancelLoading) {
|
|
||||||
clearTimeouts();
|
|
||||||
const loadingDuration = isLoadingStartTime.current
|
|
||||||
? Date.now() - isLoadingStartTime.current
|
|
||||||
: 0;
|
|
||||||
if (loadingDuration < FLICKER_THRESHOLD) {
|
|
||||||
stopLoadingTimeout.current = setTimeout(
|
|
||||||
stopLoading,
|
|
||||||
FLICKER_THRESHOLD - loadingDuration,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
stopLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [shouldCancelLoading, clearTimeouts, stopLoading]);
|
|
||||||
|
|
||||||
// Clear timeouts when unmounting
|
|
||||||
useEffect(() => () => clearTimeouts(), [clearTimeouts]);
|
|
||||||
|
|
||||||
return <Link
|
return <Link
|
||||||
{...props}
|
{...props}
|
||||||
href={href}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'transition-[colors,opacity]',
|
'transition-[colors,opacity]',
|
||||||
(loadingClassName || isControlled)
|
(loadingClassName || isControlled)
|
||||||
@ -97,27 +35,11 @@ export default function LinkWithStatus({
|
|||||||
className,
|
className,
|
||||||
isLoading && loadingClassName,
|
isLoading && loadingClassName,
|
||||||
)}
|
)}
|
||||||
onClick={e => {
|
|
||||||
const isOpeningNewTab = e.metaKey || e.ctrlKey;
|
|
||||||
if (!isVisitingLinkHref && !isOpeningNewTab) {
|
|
||||||
setPathWhenClicked(path);
|
|
||||||
startLoadingTimeout.current = setTimeout(
|
|
||||||
() => {
|
|
||||||
isLoadingStartTime.current = Date.now();
|
|
||||||
setIsLoading(true);
|
|
||||||
},
|
|
||||||
FLICKER_THRESHOLD,
|
|
||||||
);
|
|
||||||
maxLoadingTimeout.current = setTimeout(
|
|
||||||
stopLoading,
|
|
||||||
MAX_LOADING_DURATION,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
onClick?.(e);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{typeof children === 'function'
|
<LinkWithStatusChild {...{ setIsLoading }}>
|
||||||
? children({ isLoading })
|
{typeof children === 'function'
|
||||||
: children}
|
? children({ isLoading })
|
||||||
|
: children}
|
||||||
|
</LinkWithStatusChild>
|
||||||
</Link>;
|
</Link>;
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/components/primitives/LinkWithStatusChild.tsx
Normal file
47
src/components/primitives/LinkWithStatusChild.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode, useEffect, useRef } from 'react';
|
||||||
|
import { useLinkStatus } from 'next/link';
|
||||||
|
|
||||||
|
const FLICKER_THRESHOLD = 400;
|
||||||
|
|
||||||
|
export default function LinkWithStatusChild({
|
||||||
|
children,
|
||||||
|
setIsLoading,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
setIsLoading: (isLoading: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { pending } = useLinkStatus();
|
||||||
|
|
||||||
|
const startLoadingTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
const stopLoadingTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
|
const isLoadingStartTime = useRef<number>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(stopLoadingTimeout.current);
|
||||||
|
stopLoadingTimeout.current = undefined;
|
||||||
|
startLoadingTimeout.current = setTimeout(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
isLoadingStartTime.current = Date.now();
|
||||||
|
}, FLICKER_THRESHOLD);
|
||||||
|
} else if (startLoadingTimeout.current) {
|
||||||
|
clearTimeout(startLoadingTimeout.current);
|
||||||
|
startLoadingTimeout.current = undefined;
|
||||||
|
const loadingDuration = Date.now() - (isLoadingStartTime.current ?? 0);
|
||||||
|
stopLoadingTimeout.current = setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
isLoadingStartTime.current = undefined;
|
||||||
|
}, FLICKER_THRESHOLD - loadingDuration);
|
||||||
|
}
|
||||||
|
}, [pending, setIsLoading]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
clearTimeout(startLoadingTimeout.current);
|
||||||
|
clearTimeout(stopLoadingTimeout.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@ -22,8 +22,7 @@ export const vercelBlobUploadFromClient = async (
|
|||||||
): Promise<string> =>
|
): Promise<string> =>
|
||||||
upload(
|
upload(
|
||||||
fileName,
|
fileName,
|
||||||
file,
|
file, {
|
||||||
{
|
|
||||||
access: 'public',
|
access: 'public',
|
||||||
handleUploadUrl: PATH_API_VERCEL_BLOB_UPLOAD,
|
handleUploadUrl: PATH_API_VERCEL_BLOB_UPLOAD,
|
||||||
},
|
},
|
||||||
@ -34,10 +33,7 @@ export const vercelBlobPut = (
|
|||||||
file: Buffer,
|
file: Buffer,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
): Promise<string> =>
|
): Promise<string> =>
|
||||||
put(fileName, file, {
|
put(fileName, file, { access: 'public' })
|
||||||
addRandomSuffix: false,
|
|
||||||
access: 'public',
|
|
||||||
})
|
|
||||||
.then(({ url }) => url);
|
.then(({ url }) => url);
|
||||||
|
|
||||||
export const vercelBlobCopy = (
|
export const vercelBlobCopy = (
|
||||||
@ -48,10 +44,7 @@ export const vercelBlobCopy = (
|
|||||||
copy(
|
copy(
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
destinationFileName,
|
destinationFileName,
|
||||||
{
|
{ access: 'public', addRandomSuffix },
|
||||||
access: 'public',
|
|
||||||
addRandomSuffix,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.then(({ url }) => url);
|
.then(({ url }) => url);
|
||||||
|
|
||||||
|
|||||||
@ -17,9 +17,8 @@ import {
|
|||||||
} from '.';
|
} from '.';
|
||||||
import { TbChecklist } from 'react-icons/tb';
|
import { TbChecklist } from 'react-icons/tb';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { pathForRecipe } from '@/app/paths';
|
|
||||||
import LinkWithStatus from '@/components/LinkWithStatus';
|
|
||||||
import { labelForFilm } from '@/film';
|
import { labelForFilm } from '@/film';
|
||||||
|
import PhotoRecipe from './PhotoRecipe';
|
||||||
|
|
||||||
export default function PhotoRecipeOverlay({
|
export default function PhotoRecipeOverlay({
|
||||||
ref,
|
ref,
|
||||||
@ -103,20 +102,23 @@ export default function PhotoRecipeOverlay({
|
|||||||
'backdrop-blur-xl saturate-[300%]',
|
'backdrop-blur-xl saturate-[300%]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-black/90">
|
<div className={clsx(
|
||||||
<div className="grow translate-y-[-0.5px]">
|
'flex items-center gap-2 h-6',
|
||||||
|
'pl-1.5 pr-0.5',
|
||||||
|
)}>
|
||||||
|
<div className={clsx(
|
||||||
|
'grow translate-y-[-0.5px]',
|
||||||
|
'hover:opacity-50 active:opacity-75',
|
||||||
|
)}>
|
||||||
{title
|
{title
|
||||||
? <LinkWithStatus
|
? <PhotoRecipe
|
||||||
href={pathForRecipe(title ?? '')}
|
recipe={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex',
|
'text-[15px]',
|
||||||
'hover:text-black/50 active:text-black',
|
'[&>*>*>*]:text-black',
|
||||||
'px-1 py-0.5 rounded-md',
|
'tracking-wide',
|
||||||
)}
|
)}
|
||||||
loadingClassName="bg-neutral-100/20"
|
/>
|
||||||
>
|
|
||||||
{renderRecipeTitle}
|
|
||||||
</LinkWithStatus>
|
|
||||||
: renderRecipeTitle}
|
: renderRecipeTitle}
|
||||||
</div>
|
</div>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
@ -126,22 +128,23 @@ export default function PhotoRecipeOverlay({
|
|||||||
text={generateRecipeText({ title, data, film }).join('\n')}
|
text={generateRecipeText({ title, data, film }).join('\n')}
|
||||||
iconSize={17}
|
iconSize={17}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'translate-y-[0.5px]',
|
'translate-y-[1.5px]',
|
||||||
'text-black/40 active:text-black/75',
|
'text-black/40 active:text-black/75',
|
||||||
'hover:text-black/40',
|
'hover:text-black/40',
|
||||||
)}
|
)}
|
||||||
tooltip="Copy recipe text"
|
tooltip="Copy recipe text"
|
||||||
tooltipColor="frosted"
|
tooltipColor="frosted"
|
||||||
/>
|
/>
|
||||||
<LoaderButton
|
<span>
|
||||||
icon={<IoCloseCircle size={20} />}
|
<LoaderButton
|
||||||
onClick={onClose}
|
icon={<IoCloseCircle size={20} />}
|
||||||
className={clsx(
|
onClick={onClose}
|
||||||
'link p-0 m-0 h-4!',
|
className={clsx(
|
||||||
'text-black/40 active:text-black/75',
|
'link p-0 m-0',
|
||||||
'translate-y-[2.5px]',
|
'text-black/40 active:text-black/75',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-12 gap-2">
|
<div className="grid grid-cols-12 gap-2">
|
||||||
{/* ROW */}
|
{/* ROW */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user