Merge pull request #234 from sambecker/next-15-3

Next 15.3
This commit is contained in:
Sam Becker 2025-04-12 14:15:15 -05:00 committed by GitHub
commit b78a653b4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 668 additions and 842 deletions

View File

@ -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');

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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>;
}

View 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}</>;
}

View File

@ -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);

View File

@ -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 */}