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