Optimize Next.js 16 behavior (#349)
* Remove unused desktop redirect component * Tweak useEffect/setState interactions * Address more next.js 16 linting * Tweak secret loading * Finish linting setstate/useeffect interactions * Disable ref lint warnings
This commit is contained in:
parent
d061051803
commit
dbf55badf6
@ -18,9 +18,7 @@ const eslintConfig = defineConfig([
|
|||||||
'@stylistic': stylistic,
|
'@stylistic': stylistic,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// Temporarily disable during Next.js 16 migration
|
|
||||||
'react-hooks/refs': 'off',
|
'react-hooks/refs': 'off',
|
||||||
'react-hooks/set-state-in-effect': 'warn',
|
|
||||||
'@next/next/no-img-element': 'off',
|
'@next/next/no-img-element': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-require-imports': 'off',
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
|||||||
21
package.json
21
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "exif-photo-blog",
|
"name": "exif-photo-blog",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@ -10,8 +10,8 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.19.0",
|
"packageManager": "pnpm@10.19.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^2.0.53",
|
"@ai-sdk/openai": "^2.0.54",
|
||||||
"@ai-sdk/rsc": "^1.0.79",
|
"@ai-sdk/rsc": "^1.0.81",
|
||||||
"@aws-sdk/client-s3": "3.917.0",
|
"@aws-sdk/client-s3": "3.917.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.917.0",
|
"@aws-sdk/s3-request-presigner": "3.917.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@ -24,7 +24,7 @@
|
|||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@vercel/blob": "^2.0.0",
|
"@vercel/blob": "^2.0.0",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@vercel/speed-insights": "^1.2.0",
|
||||||
"ai": "^5.0.79",
|
"ai": "^5.0.81",
|
||||||
"camelcase-keys": "^10.0.0",
|
"camelcase-keys": "^10.0.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@ -37,14 +37,14 @@
|
|||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "15.5.5",
|
"next": "16.0.0",
|
||||||
"next-auth": "5.0.0-beta.29",
|
"next-auth": "5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"ol": "^10.6.1",
|
"ol": "^10.6.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"piexifjs": "^1.0.6",
|
"piexifjs": "^1.0.6",
|
||||||
"react": "19.1.1",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.1.1",
|
"react-dom": "19.2.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-openlayers": "^10.5.1",
|
"react-openlayers": "^10.5.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
@ -58,8 +58,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@next/bundle-analyzer": "15.5.5",
|
"@next/bundle-analyzer": "16.0.0",
|
||||||
"@next/eslint-plugin-next": "15.5.5",
|
"@next/eslint-plugin-next": "16.0.0",
|
||||||
"@stylistic/eslint-plugin": "^5.5.0",
|
"@stylistic/eslint-plugin": "^5.5.0",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
@ -74,8 +74,7 @@
|
|||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
"eslint": "9.38.0",
|
"eslint": "9.38.0",
|
||||||
"eslint-config-next": "15.5.5",
|
"eslint-config-next": "16.0.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
|
|||||||
724
pnpm-lock.yaml
generated
724
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ import {
|
|||||||
PREFIX_TAG,
|
PREFIX_TAG,
|
||||||
} from './src/app/path';
|
} from './src/app/path';
|
||||||
|
|
||||||
export default function middleware(req: NextRequest, res:NextResponse) {
|
export function proxy(req: NextRequest, res:NextResponse) {
|
||||||
const pathname = req.nextUrl.pathname;
|
const pathname = req.nextUrl.pathname;
|
||||||
|
|
||||||
if (pathname === PATH_ADMIN) {
|
if (pathname === PATH_ADMIN) {
|
||||||
@ -53,11 +53,10 @@ export default function AdminNavClient({
|
|||||||
useState(areTimesRecent(updateTimes));
|
useState(areTimesRecent(updateTimes));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check every 5 seconds if update times are recent
|
// Check every 1 second if update times are recent
|
||||||
setHasRecentUpdates(areTimesRecent(updateTimes));
|
|
||||||
const interval = setInterval(() =>
|
const interval = setInterval(() =>
|
||||||
setHasRecentUpdates(areTimesRecent(updateTimes))
|
setHasRecentUpdates(areTimesRecent(updateTimes))
|
||||||
, 5_000);
|
, 1_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [updateTimes]);
|
}, [updateTimes]);
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,19 @@ import { Suspense } from 'react';
|
|||||||
import { APP_CONFIGURATION } from '@/app/config';
|
import { APP_CONFIGURATION } from '@/app/config';
|
||||||
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
|
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
|
||||||
import AdminAppConfigurationServer from './AdminAppConfigurationServer';
|
import AdminAppConfigurationServer from './AdminAppConfigurationServer';
|
||||||
|
import { generateAuthSecret } from '@/auth';
|
||||||
|
|
||||||
export default function AdminAppConfiguration({
|
export default async function AdminAppConfiguration({
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
}: {
|
}: {
|
||||||
simplifiedView?: boolean
|
simplifiedView?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const secret = await generateAuthSecret();
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<AdminAppConfigurationClient {...{
|
<Suspense fallback={<AdminAppConfigurationClient {...{
|
||||||
...APP_CONFIGURATION,
|
...APP_CONFIGURATION,
|
||||||
isAnalyzingConfiguration: true,
|
isAnalyzingConfiguration: true,
|
||||||
|
secret,
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
}} /> }>
|
}} /> }>
|
||||||
<AdminAppConfigurationServer {...{ simplifiedView }} />
|
<AdminAppConfigurationServer {...{ simplifiedView }} />
|
||||||
|
|||||||
@ -137,6 +137,8 @@ export default function AdminAppConfigurationClient({
|
|||||||
areInternalToolsEnabled,
|
areInternalToolsEnabled,
|
||||||
areAdminDebugToolsEnabled,
|
areAdminDebugToolsEnabled,
|
||||||
isAdminSqlDebugEnabled,
|
isAdminSqlDebugEnabled,
|
||||||
|
// Auth
|
||||||
|
secret,
|
||||||
// Connection status
|
// Connection status
|
||||||
databaseError,
|
databaseError,
|
||||||
storageError,
|
storageError,
|
||||||
@ -147,6 +149,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
simplifiedView,
|
simplifiedView,
|
||||||
isAnalyzingConfiguration,
|
isAnalyzingConfiguration,
|
||||||
}: AppConfiguration &
|
}: AppConfiguration &
|
||||||
|
{ secret: string } &
|
||||||
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
|
||||||
simplifiedView?: boolean
|
simplifiedView?: boolean
|
||||||
isAnalyzingConfiguration?: boolean
|
isAnalyzingConfiguration?: boolean
|
||||||
@ -373,7 +376,7 @@ export default function AdminAppConfigurationClient({
|
|||||||
Store auth secret in environment variable:
|
Store auth secret in environment variable:
|
||||||
{!hasAuthSecret &&
|
{!hasAuthSecret &&
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<SecretGenerator />
|
<SecretGenerator {...{ secret }} />
|
||||||
</div>}
|
</div>}
|
||||||
{renderEnvVars(['AUTH_SECRET'])}
|
{renderEnvVars(['AUTH_SECRET'])}
|
||||||
</ChecklistRow>
|
</ChecklistRow>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
|
import AdminAppConfigurationClient from './AdminAppConfigurationClient';
|
||||||
import { APP_CONFIGURATION } from '@/app/config';
|
import { APP_CONFIGURATION } from '@/app/config';
|
||||||
import { testConnectionsAction } from '@/admin/actions';
|
import { testConnectionsAction } from '@/admin/actions';
|
||||||
|
import { generateAuthSecret } from '@/auth';
|
||||||
|
|
||||||
export default async function AdminAppConfigurationServer({
|
export default async function AdminAppConfigurationServer({
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
@ -9,10 +10,13 @@ export default async function AdminAppConfigurationServer({
|
|||||||
}) {
|
}) {
|
||||||
const connectionErrors = await testConnectionsAction().catch(() => ({}));
|
const connectionErrors = await testConnectionsAction().catch(() => ({}));
|
||||||
|
|
||||||
|
const secret = await generateAuthSecret();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminAppConfigurationClient {...{
|
<AdminAppConfigurationClient {...{
|
||||||
...APP_CONFIGURATION,
|
...APP_CONFIGURATION,
|
||||||
...connectionErrors,
|
...connectionErrors,
|
||||||
|
secret,
|
||||||
simplifiedView,
|
simplifiedView,
|
||||||
}} />
|
}} />
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,7 +22,11 @@ export default function SelectPhotosProvider({
|
|||||||
|
|
||||||
const { isUserSignedIn } = useAppState();
|
const { isUserSignedIn } = useAppState();
|
||||||
|
|
||||||
const searchParamsSelect = useClientSearchParams(PARAM_SELECT);
|
const searchParamsSelect = useClientSearchParams(
|
||||||
|
PARAM_SELECT,
|
||||||
|
// Only scan urls when admin is signed in
|
||||||
|
isUserSignedIn,
|
||||||
|
);
|
||||||
|
|
||||||
const [canCurrentPageSelectPhotos, setCanCurrentPageSelectPhotos] =
|
const [canCurrentPageSelectPhotos, setCanCurrentPageSelectPhotos] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -36,9 +40,12 @@ export default function SelectPhotosProvider({
|
|||||||
, []);
|
, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const doesPageHavePhotoGrids = getPhotoGridElements().length > 0;
|
if (isUserSignedIn) {
|
||||||
setCanCurrentPageSelectPhotos(doesPageHavePhotoGrids);
|
const doesPageHavePhotoGrids = getPhotoGridElements().length > 0;
|
||||||
}, [pathname, getPhotoGridElements]);
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setCanCurrentPageSelectPhotos(doesPageHavePhotoGrids);
|
||||||
|
}
|
||||||
|
}, [pathname, isUserSignedIn, getPhotoGridElements]);
|
||||||
|
|
||||||
const isSelectingPhotos = useMemo(() =>
|
const isSelectingPhotos = useMemo(() =>
|
||||||
isUserSignedIn &&
|
isUserSignedIn &&
|
||||||
@ -66,6 +73,7 @@ export default function SelectPhotosProvider({
|
|||||||
photoGrids[0]?.scrollIntoView({ behavior: 'smooth' });
|
photoGrids[0]?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setSelectedPhotoIds([]);
|
setSelectedPhotoIds([]);
|
||||||
}
|
}
|
||||||
}, [isSelectingPhotos, getPhotoGridElements]);
|
}, [isSelectingPhotos, getPhotoGridElements]);
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import { SWRKey } from '@/swr';
|
|||||||
|
|
||||||
export type AppStateContextType = {
|
export type AppStateContextType = {
|
||||||
// CORE
|
// CORE
|
||||||
hasLoaded?: boolean
|
|
||||||
hasLoadedWithAnimations?: boolean
|
hasLoadedWithAnimations?: boolean
|
||||||
invalidateSwr?: (key?: SWRKey, revalidate?: boolean) => void
|
invalidateSwr?: (key?: SWRKey, revalidate?: boolean) => void
|
||||||
nextPhotoAnimation?: AnimationConfig
|
nextPhotoAnimation?: AnimationConfig
|
||||||
@ -31,6 +30,7 @@ export type AppStateContextType = {
|
|||||||
typeof getCountsForCategoriesCachedAction
|
typeof getCountsForCategoriesCachedAction
|
||||||
>>
|
>>
|
||||||
// ENVIRONMENT
|
// ENVIRONMENT
|
||||||
|
timezone?: string
|
||||||
supportsHover?: boolean
|
supportsHover?: boolean
|
||||||
// MODAL
|
// MODAL
|
||||||
isCommandKOpen?: boolean
|
isCommandKOpen?: boolean
|
||||||
|
|||||||
@ -54,8 +54,6 @@ export default function AppStateProvider({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// CORE
|
// CORE
|
||||||
const [hasLoaded, setHasLoaded] =
|
|
||||||
useState(false);
|
|
||||||
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
|
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [nextPhotoAnimation, _setNextPhotoAnimation] =
|
const [nextPhotoAnimation, _setNextPhotoAnimation] =
|
||||||
@ -80,6 +78,7 @@ export default function AppStateProvider({
|
|||||||
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
|
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
|
||||||
useState(true);
|
useState(true);
|
||||||
// ENVIRONMENT
|
// ENVIRONMENT
|
||||||
|
const [timezone, setTimezone] = useState<string>();
|
||||||
const supportsHover = useSupportsHover();
|
const supportsHover = useSupportsHover();
|
||||||
// MODAL
|
// MODAL
|
||||||
const [isCommandKOpen, setIsCommandKOpen] =
|
const [isCommandKOpen, setIsCommandKOpen] =
|
||||||
@ -118,9 +117,11 @@ export default function AppStateProvider({
|
|||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasLoaded(true);
|
|
||||||
storeTimezoneCookie();
|
storeTimezoneCookie();
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setUserEmailEager(getAuthEmailCookie());
|
setUserEmailEager(getAuthEmailCookie());
|
||||||
|
// Capture backup timezone on client
|
||||||
|
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||||
if (IS_PRODUCTION) { warmRedisAction(); }
|
if (IS_PRODUCTION) { warmRedisAction(); }
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setHasLoadedWithAnimations(true);
|
setHasLoadedWithAnimations(true);
|
||||||
@ -152,6 +153,7 @@ export default function AppStateProvider({
|
|||||||
} = useSWR(SWR_KEYS.GET_AUTH, getAuthAction);
|
} = useSWR(SWR_KEYS.GET_AUTH, getAuthAction);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth === null || authError) {
|
if (auth === null || authError) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setUserEmail(undefined);
|
setUserEmail(undefined);
|
||||||
setUserEmailEager(undefined);
|
setUserEmailEager(undefined);
|
||||||
clearAuthEmailCookie();
|
clearAuthEmailCookie();
|
||||||
@ -222,7 +224,6 @@ export default function AppStateProvider({
|
|||||||
<AppStateContext.Provider
|
<AppStateContext.Provider
|
||||||
value={{
|
value={{
|
||||||
// CORE
|
// CORE
|
||||||
hasLoaded,
|
|
||||||
hasLoadedWithAnimations,
|
hasLoadedWithAnimations,
|
||||||
invalidateSwr,
|
invalidateSwr,
|
||||||
nextPhotoAnimation,
|
nextPhotoAnimation,
|
||||||
@ -233,6 +234,7 @@ export default function AppStateProvider({
|
|||||||
setShouldRespondToKeyboardCommands,
|
setShouldRespondToKeyboardCommands,
|
||||||
categoriesWithCounts,
|
categoriesWithCounts,
|
||||||
// ENVIRONMENT
|
// ENVIRONMENT
|
||||||
|
timezone,
|
||||||
supportsHover,
|
supportsHover,
|
||||||
// MODAL
|
// MODAL
|
||||||
isCommandKOpen,
|
isCommandKOpen,
|
||||||
|
|||||||
@ -4,13 +4,17 @@ import { clsx } from 'clsx/lite';
|
|||||||
import Container from '@/components/Container';
|
import Container from '@/components/Container';
|
||||||
import Spinner from '@/components/Spinner';
|
import Spinner from '@/components/Spinner';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { generateAuthSecretAction } from '@/auth/actions';
|
import { generateAuthSecretAction } from '@/auth/actions';
|
||||||
import { BiRefresh } from 'react-icons/bi';
|
import { BiRefresh } from 'react-icons/bi';
|
||||||
|
|
||||||
export default function SecretGenerator() {
|
export default function SecretGenerator({
|
||||||
|
secret: secretFromProps,
|
||||||
|
}: {
|
||||||
|
secret: string
|
||||||
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [secret, setSecret] = useState('');
|
const [secret, setSecret] = useState(secretFromProps);
|
||||||
|
|
||||||
const getSecret = useCallback(async () => {
|
const getSecret = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -19,10 +23,6 @@ export default function SecretGenerator() {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getSecret();
|
|
||||||
}, [getSecret]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Container className="my-1.5 inline-flex" padding="tight">
|
<Container className="my-1.5 inline-flex" padding="tight">
|
||||||
|
|||||||
@ -1,27 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import Switcher from '@/components/switcher/Switcher';
|
import Switcher from '@/components/switcher/Switcher';
|
||||||
import SwitcherItem from '@/components/switcher/SwitcherItem';
|
import SwitcherItem from '@/components/switcher/SwitcherItem';
|
||||||
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
||||||
import { useAppText } from '@/i18n/state/client';
|
import { useAppText } from '@/i18n/state/client';
|
||||||
|
import { useAppState } from './AppState';
|
||||||
|
|
||||||
export default function ThemeSwitcher () {
|
export default function ThemeSwitcher () {
|
||||||
|
const { hasLoadedWithAnimations } = useAppState();
|
||||||
|
|
||||||
const appText = useAppText();
|
const appText = useAppText();
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
// useEffect only runs on the client, so now we can safely show the UI
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switcher
|
<Switcher
|
||||||
// Apply offset due to outline strategy
|
// Apply offset due to outline strategy
|
||||||
@ -30,19 +22,19 @@ export default function ThemeSwitcher () {
|
|||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<BiDesktop size={16} />}
|
icon={<BiDesktop size={16} />}
|
||||||
onClick={() => setTheme('system')}
|
onClick={() => setTheme('system')}
|
||||||
active={theme === 'system'}
|
active={hasLoadedWithAnimations && theme === 'system'}
|
||||||
tooltip={{ content: appText.theme.system }}
|
tooltip={{ content: appText.theme.system }}
|
||||||
/>
|
/>
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<BiSun size={18} />}
|
icon={<BiSun size={18} />}
|
||||||
onClick={() => setTheme('light')}
|
onClick={() => setTheme('light')}
|
||||||
active={theme === 'light'}
|
active={hasLoadedWithAnimations && theme === 'light'}
|
||||||
tooltip={{ content: appText.theme.light }}
|
tooltip={{ content: appText.theme.light }}
|
||||||
/>
|
/>
|
||||||
<SwitcherItem
|
<SwitcherItem
|
||||||
icon={<BiMoon size={16} />}
|
icon={<BiMoon size={16} />}
|
||||||
onClick={() => setTheme('dark')}
|
onClick={() => setTheme('dark')}
|
||||||
active={theme === 'dark'}
|
active={hasLoadedWithAnimations && theme === 'dark'}
|
||||||
tooltip={{ content: appText.theme.dark }}
|
tooltip={{ content: appText.theme.dark }}
|
||||||
/>
|
/>
|
||||||
</Switcher>
|
</Switcher>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ function AnimateItems({
|
|||||||
onAnimationComplete,
|
onAnimationComplete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
hasLoaded,
|
hasLoadedWithAnimations,
|
||||||
nextPhotoAnimation,
|
nextPhotoAnimation,
|
||||||
getNextPhotoAnimationId,
|
getNextPhotoAnimationId,
|
||||||
clearNextPhotoAnimation,
|
clearNextPhotoAnimation,
|
||||||
@ -56,14 +56,13 @@ function AnimateItems({
|
|||||||
|
|
||||||
const prefersReducedMotion = usePrefersReducedMotion();
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
const hasLoadedInitial = useRef(hasLoaded);
|
|
||||||
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
|
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
|
||||||
|
|
||||||
const shouldAnimate = type !== 'none' &&
|
const shouldAnimate = type !== 'none' &&
|
||||||
!prefersReducedMotion &&
|
!prefersReducedMotion &&
|
||||||
!(animateOnFirstLoadOnly && hasLoadedInitial.current);
|
!(animateOnFirstLoadOnly && hasLoadedWithAnimations);
|
||||||
const shouldStagger =
|
const shouldStagger =
|
||||||
!(staggerOnFirstLoadOnly && hasLoadedInitial.current);
|
!(staggerOnFirstLoadOnly && hasLoadedWithAnimations);
|
||||||
|
|
||||||
const typeResolved = animateFromAppState
|
const typeResolved = animateFromAppState
|
||||||
? (nextPhotoAnimationInitial.current?.type ?? type)
|
? (nextPhotoAnimationInitial.current?.type ?? type)
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import useIsDesktop from '@/utility/useIsDesktop';
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function RedirectOnDesktop({
|
|
||||||
redirectPath,
|
|
||||||
shouldPrefetchRedirect = true,
|
|
||||||
}: {
|
|
||||||
redirectPath: string
|
|
||||||
shouldPrefetchRedirect?: boolean
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const isDesktop = useIsDesktop();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldPrefetchRedirect) {
|
|
||||||
router.prefetch(redirectPath);
|
|
||||||
}
|
|
||||||
}, [router, shouldPrefetchRedirect, redirectPath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDesktop && pathname !== redirectPath) {
|
|
||||||
router.push(redirectPath);
|
|
||||||
}
|
|
||||||
}, [router, isDesktop, pathname, redirectPath]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useAppState } from '@/app/AppState';
|
||||||
import { formatDate } from '@/utility/date';
|
import { formatDate } from '@/utility/date';
|
||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export default function ResponsiveDate({
|
export default function ResponsiveDate({
|
||||||
date,
|
date,
|
||||||
@ -15,13 +15,9 @@ export default function ResponsiveDate({
|
|||||||
className?: string
|
className?: string
|
||||||
titleLabel?: string
|
titleLabel?: string
|
||||||
} & Parameters<typeof formatDate>[0]) {
|
} & Parameters<typeof formatDate>[0]) {
|
||||||
const [timezone, setTimezone] = useState(timezoneFromProps);
|
const { timezone: timezoneFromState } = useAppState();
|
||||||
|
|
||||||
useEffect(() => {
|
const timezone = timezoneFromProps ?? timezoneFromState;
|
||||||
if (!timezoneFromProps) {
|
|
||||||
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
||||||
}
|
|
||||||
}, [timezoneFromProps]);
|
|
||||||
|
|
||||||
const showPlaceholder = timezone === undefined;
|
const showPlaceholder = timezone === undefined;
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export default function SelectMenu({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}, [readOnly]);
|
}, [readOnly]);
|
||||||
|
|||||||
@ -193,6 +193,7 @@ export default function TagInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputText) {
|
if (inputText) {
|
||||||
if (inputText.includes(',')) {
|
if (inputText.includes(',')) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
addOptions(inputText.split(','));
|
addOptions(inputText.split(','));
|
||||||
} else {
|
} else {
|
||||||
setShouldShowMenu(true);
|
setShouldShowMenu(true);
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export default function ImageWithFallback({
|
|||||||
!ref.current?.complete ||
|
!ref.current?.complete ||
|
||||||
(ref.current?.naturalWidth ?? 0) === 0
|
(ref.current?.naturalWidth ?? 0) === 0
|
||||||
) {
|
) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setFadeFallbackTransition(true);
|
setFadeFallbackTransition(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export default function OGLoaderImage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current?.complete) {
|
if (!ref.current?.complete) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setLoadingState('loading');
|
setLoadingState('loading');
|
||||||
}
|
}
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export default function PathLoaderButton({
|
|||||||
}, loaderDelay);
|
}, loaderDelay);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
} else {
|
} else {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setShouldShowLoader(false);
|
setShouldShowLoader(false);
|
||||||
}
|
}
|
||||||
}, [isPending, loaderDelay]);
|
}, [isPending, loaderDelay]);
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import Spinner from '../Spinner';
|
|||||||
import LinkWithIconLoader from '../LinkWithIconLoader';
|
import LinkWithIconLoader from '../LinkWithIconLoader';
|
||||||
import Tooltip from '../Tooltip';
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
const WIDTH_CLASS = 'w-[42px]';
|
const WIDTH_CLASS = 'w-[42px]';
|
||||||
const WIDTH_CLASS_NARROW = 'w-[36px]';
|
const WIDTH_CLASS_NARROW = 'w-[36px]';
|
||||||
|
|
||||||
export default function SwitcherItem({
|
export default function SwitcherItem({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export default function useTitleCaptionAiImageQuery(
|
|||||||
const [caption, setCaption] = useState('');
|
const [caption, setCaption] = useState('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { title, caption } = parseTitleAndCaption(text);
|
const { title, caption } = parseTitleAndCaption(text);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setTitle(title);
|
setTitle(title);
|
||||||
setCaption(caption);
|
setCaption(caption);
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|||||||
@ -102,31 +102,31 @@ const getPhotosCacheKeys = (options: PhotoQueryOptions = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const revalidatePhotosKey = () =>
|
export const revalidatePhotosKey = () =>
|
||||||
revalidateTag(KEY_PHOTOS);
|
revalidateTag(KEY_PHOTOS, 'max');
|
||||||
|
|
||||||
export const revalidateAlbumsKey = () =>
|
export const revalidateAlbumsKey = () =>
|
||||||
revalidateTag(KEY_ALBUMS);
|
revalidateTag(KEY_ALBUMS, 'max');
|
||||||
|
|
||||||
export const revalidateTagsKey = () =>
|
export const revalidateTagsKey = () =>
|
||||||
revalidateTag(KEY_TAGS);
|
revalidateTag(KEY_TAGS, 'max');
|
||||||
|
|
||||||
export const revalidateRecipesKey = () =>
|
export const revalidateRecipesKey = () =>
|
||||||
revalidateTag(KEY_RECIPES);
|
revalidateTag(KEY_RECIPES, 'max');
|
||||||
|
|
||||||
export const revalidateCamerasKey = () =>
|
export const revalidateCamerasKey = () =>
|
||||||
revalidateTag(KEY_CAMERAS);
|
revalidateTag(KEY_CAMERAS, 'max');
|
||||||
|
|
||||||
export const revalidateLensesKey = () =>
|
export const revalidateLensesKey = () =>
|
||||||
revalidateTag(KEY_LENSES);
|
revalidateTag(KEY_LENSES, 'max');
|
||||||
|
|
||||||
export const revalidateFilmsKey = () =>
|
export const revalidateFilmsKey = () =>
|
||||||
revalidateTag(KEY_FILMS);
|
revalidateTag(KEY_FILMS, 'max');
|
||||||
|
|
||||||
export const revalidateFocalLengthsKey = () =>
|
export const revalidateFocalLengthsKey = () =>
|
||||||
revalidateTag(KEY_FOCAL_LENGTHS);
|
revalidateTag(KEY_FOCAL_LENGTHS, 'max');
|
||||||
|
|
||||||
export const revalidateYearsKey = () =>
|
export const revalidateYearsKey = () =>
|
||||||
revalidateTag(KEY_YEARS);
|
revalidateTag(KEY_YEARS, 'max');
|
||||||
|
|
||||||
export const revalidateAllKeys = () => {
|
export const revalidateAllKeys = () => {
|
||||||
revalidatePhotosKey();
|
revalidatePhotosKey();
|
||||||
@ -151,7 +151,7 @@ export const revalidateAllKeysAndPaths = () => {
|
|||||||
|
|
||||||
export const revalidatePhoto = (photoId: string) => {
|
export const revalidatePhoto = (photoId: string) => {
|
||||||
// Tags
|
// Tags
|
||||||
revalidateTag(photoId);
|
revalidateTag(photoId, 'max');
|
||||||
revalidateYearsKey();
|
revalidateYearsKey();
|
||||||
revalidateCamerasKey();
|
revalidateCamerasKey();
|
||||||
revalidateLensesKey();
|
revalidateLensesKey();
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export default function ApplyRecipeTitleGloballyCheckbox({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recipeTitle && hasRecipeTitleChanged && recipeData && film) {
|
if (recipeTitle && hasRecipeTitleChanged && recipeData && film) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setMatchingPhotosCount(undefined);
|
setMatchingPhotosCount(undefined);
|
||||||
getPhotosNeedingRecipeTitleCountAction(recipeData, film, photoId)
|
getPhotosNeedingRecipeTitleCountAction(recipeData, film, photoId)
|
||||||
.then(setMatchingPhotosCount);
|
.then(setMatchingPhotosCount);
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -371,7 +372,7 @@ export default function PhotoForm({
|
|||||||
blurCompatibilityLevel="none"
|
blurCompatibilityLevel="none"
|
||||||
width={thumbnailDimensions.width}
|
width={thumbnailDimensions.width}
|
||||||
height={thumbnailDimensions.height}
|
height={thumbnailDimensions.height}
|
||||||
priority
|
preload
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export default function PlaceInput({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputTextDebounced) {
|
if (inputTextDebounced) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsLoadingPlaces(true);
|
setIsLoadingPlaces(true);
|
||||||
getPlaceAutoCompleteAction(inputTextDebounced)
|
getPlaceAutoCompleteAction(inputTextDebounced)
|
||||||
.then(options => {
|
.then(options => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
export default function useClientSearchParams(
|
export default function useClientSearchParams(
|
||||||
paramKey: string,
|
paramKey: string,
|
||||||
|
enableScanning = true,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
@ -23,7 +24,10 @@ export default function useClientSearchParams(
|
|||||||
};
|
};
|
||||||
}, [captureParam]);
|
}, [captureParam]);
|
||||||
|
|
||||||
useEffect(captureParam, [captureParam, pathname]);
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
if (enableScanning) { captureParam(); }
|
||||||
|
}, [pathname, captureParam, enableScanning]);
|
||||||
|
|
||||||
return paramValue;
|
return paramValue;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,9 +17,8 @@ export default function useHash() {
|
|||||||
|
|
||||||
// Needed to capture non-request-initiated hash changes
|
// Needed to capture non-request-initiated hash changes
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
useEffect(() => {
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
storeHash();
|
useEffect(storeHash, [params, storeHash]);
|
||||||
}, [params, storeHash]);
|
|
||||||
|
|
||||||
const updateWindowHash = useCallback((hash: string) => {
|
const updateWindowHash = useCallback((hash: string) => {
|
||||||
window.history.replaceState(null, '', `#${hash}`);
|
window.history.replaceState(null, '', `#${hash}`);
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export default function useIsDesktop() {
|
|
||||||
const [isDesktop, setIsDesktop] = useState<boolean>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const breakpointMd = getComputedStyle(document.body)
|
|
||||||
.getPropertyValue('--breakpoint-md');
|
|
||||||
const mql = window.matchMedia(`(min-width: ${breakpointMd})`);
|
|
||||||
setIsDesktop(mql.matches);
|
|
||||||
|
|
||||||
const eventHandler = (event: MediaQueryListEvent) =>
|
|
||||||
setIsDesktop(event.matches);
|
|
||||||
|
|
||||||
mql.addEventListener('change', eventHandler);
|
|
||||||
return () => mql.removeEventListener('change', eventHandler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return isDesktop;
|
|
||||||
};
|
|
||||||
@ -5,14 +5,16 @@ export default function useSupportsHover() {
|
|||||||
? window.matchMedia('(hover: hover)')
|
? window.matchMedia('(hover: hover)')
|
||||||
: undefined);
|
: undefined);
|
||||||
|
|
||||||
const [supportsHover, setSupportsHover] =
|
const [supportsHover, setSupportsHover] = useState<boolean>(false);
|
||||||
useState<boolean>(mqlRef.current?.matches ?? false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches);
|
|
||||||
const mql = mqlRef.current;
|
const mql = mqlRef.current;
|
||||||
mql?.addEventListener('change', listener);
|
if (mql) {
|
||||||
return () => mql?.removeEventListener('change', listener);
|
setSupportsHover(mql.matches);
|
||||||
|
const listener = (e: MediaQueryListEvent) => setSupportsHover(e.matches);
|
||||||
|
mql.addEventListener('change', listener);
|
||||||
|
return () => mql?.removeEventListener('change', listener);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return supportsHover;
|
return supportsHover;
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user