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:
parent
e56b386a20
commit
5591635a1e
@ -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;
|
||||
|
||||
|
||||
52
package.json
52
package.json
@ -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
3508
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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) {
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -32,8 +32,8 @@ export default function PhotoGridInfinite({
|
||||
excludeFromFeeds={excludeFromFeeds}
|
||||
{...categories}
|
||||
>
|
||||
{({ photos, onLastPhotoVisible }) =>
|
||||
<PhotoGrid {...{
|
||||
{({ key, photos, onLastPhotoVisible }) =>
|
||||
<PhotoGrid key={key} {...{
|
||||
photos,
|
||||
...categories,
|
||||
canStart,
|
||||
|
||||
@ -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} ...`,
|
||||
});
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -17,8 +17,9 @@ export default function StaggeredOgPhotosInfinite({
|
||||
initialOffset={initialOffset}
|
||||
itemsPerPage={itemsPerPage}
|
||||
>
|
||||
{({ photos, onLastPhotoVisible }) =>
|
||||
{({ key, photos, onLastPhotoVisible }) =>
|
||||
<StaggeredOgPhotos
|
||||
key={key}
|
||||
photos={photos}
|
||||
onLastPhotoVisible={onLastPhotoVisible}
|
||||
/>}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user