Next.js 16 (#347)

* Upgrade to Next.js 16, resolve/suppress linting errors

* Update usage of revalidateTag()

* Rename proxy.ts export

* Refactor infinite scroll data handling
This commit is contained in:
Sam Becker 2025-10-25 21:35:30 -05:00 committed by GitHub
parent e56b386a20
commit 5591635a1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1880 additions and 1955 deletions

View File

@ -1,27 +1,26 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
import stylistic from '@stylistic/eslint-plugin';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [{
ignores: [
'.*',
'node_modules',
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
],
},
...compat.extends('next/core-web-vitals', 'next/typescript'), {
]), {
plugins: {
'@stylistic': stylistic,
},
rules: {
// Temporarily disable during Next.js 16 migration
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'@next/next/no-img-element': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
@ -55,6 +54,8 @@ const eslintConfig = [{
{ 'code': 80 },
],
},
}];
},
]);
export default eslintConfig;

View File

@ -1,30 +1,30 @@
{
"name": "exif-photo-blog",
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build"
},
"packageManager": "pnpm@10.18.2",
"packageManager": "pnpm@10.19.0",
"dependencies": {
"@ai-sdk/openai": "^2.0.40",
"@ai-sdk/rsc": "^1.0.59",
"@aws-sdk/client-s3": "3.899.0",
"@aws-sdk/s3-request-presigner": "3.899.0",
"@ai-sdk/openai": "^2.0.53",
"@ai-sdk/rsc": "^1.0.79",
"@aws-sdk/client-s3": "3.917.0",
"@aws-sdk/s3-request-presigner": "3.917.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@types/piexifjs": "^1.0.0",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@upstash/redis": "^1.35.6",
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^2.0.0",
"@vercel/speed-insights": "^1.2.0",
"ai": "^5.0.59",
"ai": "^5.0.79",
"camelcase-keys": "^10.0.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -35,16 +35,16 @@
"extract-colors": "^4.2.1",
"fast-average-color": "^9.5.0",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^12.23.22",
"framer-motion": "^12.23.24",
"nanoid": "^5.1.6",
"next": "15.5.4",
"next": "16.0.0",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"ol": "^10.6.1",
"pg": "^8.16.3",
"piexifjs": "^1.0.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-icons": "^5.5.0",
"react-openlayers": "^10.5.1",
"sanitize-html": "^2.17.0",
@ -54,34 +54,34 @@
"ts-exif-parser": "^0.2.2",
"use-debounce": "^10.0.6",
"viewerjs": "^1.11.7",
"zod": "^4.1.11"
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@next/bundle-analyzer": "15.5.4",
"@next/eslint-plugin-next": "15.5.4",
"@stylistic/eslint-plugin": "^5.4.0",
"@tailwindcss/postcss": "^4.1.13",
"@next/bundle-analyzer": "16.0.0",
"@next/eslint-plugin-next": "16.0.0",
"@stylistic/eslint-plugin": "^5.5.0",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/culori": "^4.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^24.6.0",
"@types/node": "^24.9.1",
"@types/pg": "^8.15.5",
"@types/react": "19.1.15",
"@types/react-dom": "19.1.9",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
"eslint": "9.36.0",
"eslint-config-next": "15.5.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint": "9.38.0",
"eslint-config-next": "16.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "8.5.6",
"tailwindcss": "4.1.13",
"tailwindcss": "4.1.16",
"ts-node": "^10.9.2",
"typescript": "5.9.2"
"typescript": "5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [

3508
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ import {
PREFIX_TAG,
} from './src/app/path';
export default function middleware(req: NextRequest, res:NextResponse) {
export function proxy(req: NextRequest, res:NextResponse) {
const pathname = req.nextUrl.pathname;
if (pathname === PATH_ADMIN) {

View File

@ -25,8 +25,9 @@ export default function AdminPhotosTableInfinite({
sortBy="createdAt"
includeHiddenPhotos
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
{({ key, photos, onLastPhotoVisible, revalidatePhoto }) =>
<AdminPhotosTable
key={key}
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}

View File

@ -62,12 +62,12 @@ export default function AppStateProvider({
useState(false);
const [nextPhotoAnimation, _setNextPhotoAnimation] =
useState<AnimationConfig>();
const [nextPhotoAnimationId, setNextPhotoAnimationId] =
useState<string>();
const setNextPhotoAnimation = useCallback((animation?: AnimationConfig) => {
_setNextPhotoAnimation(animation);
setNextPhotoAnimationId(undefined);
}, []);
const [nextPhotoAnimationId, setNextPhotoAnimationId] =
useState<string>();
const getNextPhotoAnimationId = useCallback(() => {
const id = nanoid();
setNextPhotoAnimationId(id);

View File

@ -83,6 +83,8 @@ export default function AppViewSwitcher({
const refHrefFull = useRef<HTMLAnchorElement>(null);
const refHrefGrid = useRef<HTMLAnchorElement>(null);
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (!e.metaKey) {
switch (e.key.toLocaleUpperCase()) {
@ -101,7 +103,6 @@ export default function AppViewSwitcher({
useKeydownHandler({ onKeyDown });
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
const [isAdminMenuOpen, setIsAdminMenuOpen] = useState(false);
const renderItemFull =
<SwitcherItem
@ -135,7 +136,7 @@ export default function AppViewSwitcher({
className={clsx(
GAP_CLASS_RIGHT,
// Apply offset due to outline strategy
'translate-x-[1px]',
'translate-x-px',
)}
>
{GRID_HOMEPAGE_ENABLED ? renderItemGrid : renderItemFull}
@ -209,7 +210,7 @@ export default function AppViewSwitcher({
href={pathSortToggle}
icon={<IconSort
sort={isAscending ? 'asc' : 'desc'}
className="translate-x-[0.5px] translate-y-[1px]"
className="translate-x-[0.5px] translate-y-px"
/>}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: isAscending

View File

@ -1,7 +1,7 @@
'use client';
import { ReactNode, useRef } from 'react';
import { Variant, motion } from 'framer-motion';
import { Variant, motion, stagger } from 'framer-motion';
import { useAppState } from '@/app/AppState';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
@ -73,7 +73,8 @@ function AnimateItems({
? (nextPhotoAnimationInitial.current?.duration ?? duration)
: duration;
const getInitialVariant = (): Variant => {
const hidden: Variant =
(() => {
switch (typeResolved) {
case 'left': return {
opacity: 0,
@ -91,8 +92,7 @@ function AnimateItems({
opacity: 0,
transform: `translateY(${distanceOffset}px) scale(${scaleOffset})`,
};
}
};
}})();
return (
<motion.div
@ -103,7 +103,7 @@ function AnimateItems({
? {
show: {
transition: {
staggerChildren: staggerDelay,
delayChildren: stagger(staggerDelay),
},
},
} : undefined}
@ -122,7 +122,7 @@ function AnimateItems({
key={itemKeys ? itemKeys[index] : index}
className={classNameItem}
variants={{
hidden: getInitialVariant(),
hidden,
show: {
opacity: 1,
transform: 'translateX(0) translateY(0) scale(1)',

View File

@ -59,11 +59,11 @@ export default function useMaskedScroll({
useEffect(() => {
const ref = containerRef?.current;
if (ref && updateMaskOnEvents) {
ref.onscroll = updateMask;
ref.onresize = updateMask;
ref.addEventListener('scroll', updateMask);
ref.addEventListener('resize', updateMask);
return () => {
ref.onscroll = null;
ref.onresize = null;
ref.removeEventListener('scroll', updateMask);
ref.removeEventListener('resize', updateMask);
};
}
}, [containerRef, updateMask, updateMaskOnEvents]);

View File

@ -58,6 +58,7 @@ export default function InfinitePhotoScroll({
useCachedPhotos?: boolean
includeHiddenPhotos?: boolean
children: (props: {
key: string
photos: Photo[]
onLastPhotoVisible: () => void
revalidatePhoto?: RevalidatePhoto
@ -140,8 +141,6 @@ export default function InfinitePhotoScroll({
}
}, [isFinished, isLoadingOrValidating, setSize]);
const photos = useMemo(() => (data ?? [])?.flat(), [data]);
const revalidatePhoto: RevalidatePhoto = useCallback((
photoId: string,
revalidateRemainingPhotos?: boolean,
@ -159,7 +158,7 @@ export default function InfinitePhotoScroll({
}
}});
const renderMoreButton = () =>
const renderMoreButton =
<div ref={buttonContainerRef}>
<button
type="button"
@ -179,15 +178,20 @@ export default function InfinitePhotoScroll({
</div>;
return (
<div className="space-y-4">
{children({
<>
{data?.map((photos, index) => (
children({
key: `${cacheKey}-${index}`,
photos,
onLastPhotoVisible: advance,
revalidatePhoto,
})}
{!isFinished && (wrapMoreButtonInGrid
? <AppGrid contentMain={renderMoreButton()} />
: renderMoreButton())}
</div>
})
))}
{!isFinished && <div className="mt-4">
{wrapMoreButtonInGrid
? <AppGrid contentMain={renderMoreButton} />
: renderMoreButton}
</div>}
</>
);
}

View File

@ -32,8 +32,8 @@ export default function PhotoGridInfinite({
excludeFromFeeds={excludeFromFeeds}
{...categories}
>
{({ photos, onLastPhotoVisible }) =>
<PhotoGrid {...{
{({ key, photos, onLastPhotoVisible }) =>
<PhotoGrid key={key} {...{
photos,
...categories,
canStart,

View File

@ -65,11 +65,11 @@ export default function PhotoPrevNextActions({
const toggleFavorite = useCallback(() => {
if (photo?.id) { return toggleFavoritePhotoAction(photo.id); }
}, [photo?.id]);
}, [photo]);
const toggleHidden = useCallback(() => {
if (photo?.id) { return togglePrivatePhotoAction(photo.id); }
}, [photo?.id]);
}, [photo]);
const navigateToPhotoEdit = useNavigateOrRunActionWithToast({
pathOrAction: photo ? pathForAdminPhotoEdit(photo) : undefined,
@ -99,7 +99,7 @@ export default function PhotoPrevNextActions({
const syncPhoto = useNavigateOrRunActionWithToast({
pathOrAction: useCallback(() => {
if (photo?.id) { return syncPhotoAction(photo.id); }
}, [photo?.id]),
}, [photo]),
toastMessage: `Syncing ${photoTitle} ...`,
});
@ -108,7 +108,7 @@ export default function PhotoPrevNextActions({
if (photo?.id && photo.url) {
return deletePhotoAction(photo.id, photo.url, true);
}
}, [photo?.id, photo?.url]),
}, [photo]),
toastMessage: `Deleting ${photoTitle} ...`,
});

View File

@ -26,8 +26,9 @@ export default function PhotosLargeInfinite({
excludeFromFeeds={excludeFromFeeds}
wrapMoreButtonInGrid
>
{({ photos, onLastPhotoVisible, revalidatePhoto }) =>
{({ key, photos, onLastPhotoVisible, revalidatePhoto }) =>
<PhotosLarge
key={key}
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
revalidatePhoto={revalidatePhoto}

View File

@ -17,8 +17,9 @@ export default function StaggeredOgPhotosInfinite({
initialOffset={initialOffset}
itemsPerPage={itemsPerPage}
>
{({ photos, onLastPhotoVisible }) =>
{({ key, photos, onLastPhotoVisible }) =>
<StaggeredOgPhotos
key={key}
photos={photos}
onLastPhotoVisible={onLastPhotoVisible}
/>}

View File

@ -102,31 +102,31 @@ const getPhotosCacheKeys = (options: PhotoQueryOptions = {}) => {
};
export const revalidatePhotosKey = () =>
revalidateTag(KEY_PHOTOS);
revalidateTag(KEY_PHOTOS, 'max');
export const revalidateAlbumsKey = () =>
revalidateTag(KEY_ALBUMS);
revalidateTag(KEY_ALBUMS, 'max');
export const revalidateTagsKey = () =>
revalidateTag(KEY_TAGS);
revalidateTag(KEY_TAGS, 'max');
export const revalidateRecipesKey = () =>
revalidateTag(KEY_RECIPES);
revalidateTag(KEY_RECIPES, 'max');
export const revalidateCamerasKey = () =>
revalidateTag(KEY_CAMERAS);
revalidateTag(KEY_CAMERAS, 'max');
export const revalidateLensesKey = () =>
revalidateTag(KEY_LENSES);
revalidateTag(KEY_LENSES, 'max');
export const revalidateFilmsKey = () =>
revalidateTag(KEY_FILMS);
revalidateTag(KEY_FILMS, 'max');
export const revalidateFocalLengthsKey = () =>
revalidateTag(KEY_FOCAL_LENGTHS);
revalidateTag(KEY_FOCAL_LENGTHS, 'max');
export const revalidateYearsKey = () =>
revalidateTag(KEY_YEARS);
revalidateTag(KEY_YEARS, 'max');
export const revalidateAllKeys = () => {
revalidatePhotosKey();
@ -151,7 +151,7 @@ export const revalidateAllKeysAndPaths = () => {
export const revalidatePhoto = (photoId: string) => {
// Tags
revalidateTag(photoId);
revalidateTag(photoId, 'max');
revalidateYearsKey();
revalidateCamerasKey();
revalidateLensesKey();

View File

@ -334,7 +334,7 @@ export default function PhotoForm({
? 'true'
: 'false',
}));
}, []);
}, [setFormData]);
const formContent = useMemo(() =>
FORM_METADATA_ENTRIES_BY_SECTION(
@ -371,7 +371,7 @@ export default function PhotoForm({
blurCompatibilityLevel="none"
width={thumbnailDimensions.width}
height={thumbnailDimensions.height}
priority
preload
/>;
return (

View File

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2019",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}