Next.js 16, React 19.2, Identities page, Invitations identities step, PNPM Catalogs (#381)

* Upgraded to Next.js 16
* Refactored code to comply with React 19.2 ESLint rules
* Refactored some useEffect usages with the new useEffectEvent
* Added Identities page and added second step to set up an identity after accepting an invitation
* Updated all dependencies
* Introduced PNPM catalogs for some frequently updated dependencies
* Bugs fixing and improvements
This commit is contained in:
Giancarlo Buomprisco
2025-10-22 11:47:47 +09:00
committed by GitHub
parent ea0c1dde80
commit 2c0d0bf7a1
98 changed files with 4812 additions and 4394 deletions

View File

@@ -14,7 +14,7 @@
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"input-otp": "1.4.2",
"lucide-react": "^0.544.0",
"lucide-react": "^0.546.0",
"radix-ui": "1.4.3",
"react-dropzone": "^14.3.8",
"react-top-loading-bar": "3.0.2",
@@ -25,22 +25,22 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.58.0",
"@tanstack/react-query": "5.90.2",
"@supabase/supabase-js": "2.76.1",
"@tanstack/react-query": "5.90.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.16",
"@types/react-dom": "19.1.9",
"@types/react": "catalog:",
"@types/react-dom": "19.2.2",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"eslint": "^9.35.0",
"next": "15.5.5",
"eslint": "^9.38.0",
"next": "16.0.0",
"next-themes": "0.4.6",
"prettier": "^3.6.2",
"react-day-picker": "^9.11.0",
"react-hook-form": "^7.63.0",
"react-i18next": "^16.0.0",
"react-day-picker": "^9.11.1",
"react-hook-form": "^7.65.0",
"react-i18next": "^16.1.4",
"sonner": "^2.0.7",
"tailwindcss": "4.1.14",
"tailwindcss": "4.1.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"zod": "^3.25.74"

View File

@@ -196,19 +196,23 @@ export const useSupabaseUpload = (options: UseSupabaseUploadOptions) => {
setLoading(false);
}, [
files,
path,
bucketName,
errors,
successes,
onUploadSuccess,
client,
cacheControl,
client.storage,
errors,
files,
onUploadSuccess,
setLoading,
setErrors,
setSuccesses,
path,
successes,
upsert,
]);
useEffect(() => {
if (files.length === 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setErrors([]);
}

View File

@@ -109,30 +109,19 @@ export const ImageUploadInput: React.FC<Props> =
[forwardedRef],
);
useEffect(() => {
if (image !== state.image) {
setState((state) => ({ ...state, image }));
}, [image]);
}
useEffect(() => {
if (!image) {
// eslint-disable-next-line react-hooks/set-state-in-effect
onRemove();
}
}, [image, onRemove]);
const Input = () => (
<input
{...props}
className={cn('hidden', props.className)}
ref={setRef}
type={'file'}
onInput={onInputChange}
accept="image/*"
aria-labelledby={'image-upload-input'}
/>
);
if (!visible) {
return <Input />;
return <Input {...props} onInput={onInputChange} ref={setRef} />;
}
return (
@@ -140,7 +129,7 @@ export const ImageUploadInput: React.FC<Props> =
id={'image-upload-input'}
className={`border-input bg-background ring-primary ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring relative flex h-10 w-full cursor-pointer rounded-md border border-dashed px-3 py-2 text-sm ring-offset-2 outline-hidden transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50`}
>
<Input />
<Input ref={setRef} onInput={onInputChange} />
<div className={'flex items-center space-x-4'}>
<div className={'flex'}>
@@ -198,3 +187,19 @@ export const ImageUploadInput: React.FC<Props> =
</label>
);
};
function Input(
props: React.InputHTMLAttributes<unknown> & {
ref: (input: HTMLInputElement) => void;
},
) {
return (
<input
{...props}
className={cn('hidden', props.className)}
type={'file'}
accept="image/*"
aria-labelledby={'image-upload-input'}
/>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { Image as ImageIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@@ -44,25 +44,21 @@ export function ImageUploader(
[props],
);
const Input = () => (
<ImageUploadInput
{...control}
accept={'image/*'}
className={'absolute h-full w-full'}
visible={false}
multiple={false}
onValueChange={onValueChange}
/>
);
useEffect(() => {
if (props.value !== image) {
setImage(props.value);
}, [props.value]);
}
if (!image) {
return (
<FallbackImage descriptionSection={props.children}>
<Input />
<ImageUploadInput
{...control}
accept={'image/*'}
className={'absolute h-full w-full'}
visible={false}
multiple={false}
onValueChange={onValueChange}
/>
</FallbackImage>
);
}
@@ -84,7 +80,14 @@ export function ImageUploader(
alt={''}
/>
<Input />
<ImageUploadInput
{...control}
accept={'image/*'}
className={'absolute h-full w-full'}
visible={false}
multiple={false}
onValueChange={onValueChange}
/>
</label>
<div>

View File

@@ -386,6 +386,7 @@ function AnimatedStep({
useEffect(() => {
if (isActive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 300);

View File

@@ -1,6 +1,6 @@
import Image from 'next/image';
import { AtSign, Phone } from 'lucide-react';
import { AtSign, Mail, Phone } from 'lucide-react';
const DEFAULT_IMAGE_SIZE = 18;
@@ -33,7 +33,8 @@ export function OauthProviderLogoImage({
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
return {
email: <AtSign className={'size-[16px]'} />,
password: <AtSign className={'s-[18px]'} />,
email: <Mail className={'s-[18px]'} />,
phone: <Phone className={'size-[16px]'} />,
google: '/images/oauth/google.webp',
facebook: '/images/oauth/facebook.webp',

View File

@@ -1,6 +1,6 @@
'use client';
import { useContext, useId, useRef, useState } from 'react';
import { useContext, useId, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -43,23 +43,20 @@ export function Sidebar(props: {
}) => React.ReactNode);
}) {
const [collapsed, setCollapsed] = useState(props.collapsed ?? false);
const isExpandedRef = useRef<boolean>(false);
const [isExpanded, setIsExpanded] = useState(false);
const expandOnHover =
props.expandOnHover ??
process.env.NEXT_PUBLIC_EXPAND_SIDEBAR_ON_HOVER === 'true';
const sidebarSizeClassName = getSidebarSizeClassName(
collapsed,
isExpandedRef.current,
);
const sidebarSizeClassName = getSidebarSizeClassName(collapsed, isExpanded);
const className = getClassNameBuilder(
cn(props.className ?? '', sidebarSizeClassName, {}),
)();
const containerClassName = cn(sidebarSizeClassName, 'bg-inherit', {
'max-w-[4rem]': expandOnHover && isExpandedRef.current,
'max-w-[4rem]': expandOnHover && isExpanded,
});
const ctx = { collapsed, setCollapsed };
@@ -68,7 +65,7 @@ export function Sidebar(props: {
props.collapsed && expandOnHover
? () => {
setCollapsed(false);
isExpandedRef.current = true;
setIsExpanded(true);
}
: undefined;
@@ -77,11 +74,11 @@ export function Sidebar(props: {
? () => {
if (!isRadixPopupOpen()) {
setCollapsed(true);
isExpandedRef.current = false;
setIsExpanded(false);
} else {
onRadixPopupClose(() => {
setCollapsed(true);
isExpandedRef.current = false;
setIsExpanded(false);
});
}
}
@@ -124,6 +121,66 @@ export function SidebarContent({
return <div className={className}>{children}</div>;
}
function SidebarGroupWrapper({
id,
sidebarCollapsed,
collapsible,
isGroupCollapsed,
setIsGroupCollapsed,
label,
}: {
id: string;
sidebarCollapsed: boolean;
collapsible: boolean;
isGroupCollapsed: boolean;
setIsGroupCollapsed: (isGroupCollapsed: boolean) => void;
label: React.ReactNode;
}) {
const className = cn(
'px-container group flex items-center justify-between space-x-2.5',
{
'py-2.5': !sidebarCollapsed,
},
);
if (collapsible) {
return (
<button
aria-expanded={!isGroupCollapsed}
aria-controls={id}
onClick={() => setIsGroupCollapsed(!isGroupCollapsed)}
className={className}
>
<span
className={'text-muted-foreground text-xs font-semibold uppercase'}
>
{label}
</span>
<If condition={collapsible}>
<ChevronDown
className={cn(`h-3 transition duration-300`, {
'rotate-180': !isGroupCollapsed,
})}
/>
</If>
</button>
);
}
if (sidebarCollapsed) {
return null;
}
return (
<div className={className}>
<span className={'text-muted-foreground text-xs font-semibold uppercase'}>
{label}
</span>
</div>
);
}
export function SidebarGroup({
label,
collapsed = false,
@@ -138,61 +195,20 @@ export function SidebarGroup({
const [isGroupCollapsed, setIsGroupCollapsed] = useState(collapsed);
const id = useId();
const Title = (props: React.PropsWithChildren) => {
if (sidebarCollapsed) {
return null;
}
return (
<span className={'text-muted-foreground text-xs font-semibold uppercase'}>
{props.children}
</span>
);
};
const Wrapper = () => {
const className = cn(
'px-container group flex items-center justify-between space-x-2.5',
{
'py-2.5': !sidebarCollapsed,
},
);
if (collapsible) {
return (
<button
aria-expanded={!isGroupCollapsed}
aria-controls={id}
onClick={() => setIsGroupCollapsed(!isGroupCollapsed)}
className={className}
>
<Title>{label}</Title>
<If condition={collapsible}>
<ChevronDown
className={cn(`h-3 transition duration-300`, {
'rotate-180': !isGroupCollapsed,
})}
/>
</If>
</button>
);
}
return (
<div className={className}>
<Title>{label}</Title>
</div>
);
};
return (
<div
className={cn('flex flex-col', {
'gap-y-2 py-1': !collapsed,
})}
>
<Wrapper />
<SidebarGroupWrapper
id={id}
sidebarCollapsed={sidebarCollapsed}
collapsible={collapsible}
isGroupCollapsed={isGroupCollapsed}
setIsGroupCollapsed={setIsGroupCollapsed}
label={label}
/>
<If condition={collapsible ? !isGroupCollapsed : true}>
<div id={id} className={'flex flex-col space-y-1.5'}>

View File

@@ -1,6 +1,6 @@
'use client';
import { Fragment, useCallback } from 'react';
import { Fragment } from 'react';
import { cva } from 'class-variance-authority';
@@ -12,6 +12,54 @@ type Variant = 'numbers' | 'default' | 'dots';
const classNameBuilder = getClassNameBuilder();
function Steps({
steps,
currentStep,
variant,
}: {
steps: string[];
currentStep: number;
variant?: Variant;
}) {
return steps.map((labelOrKey, index) => {
const selected = currentStep === index;
const complete = currentStep > index;
const className = classNameBuilder({
selected,
variant,
complete,
});
const isNumberVariant = variant === 'numbers';
const isDotsVariant = variant === 'dots';
const labelClassName = cn({
['px-1.5 py-2 text-xs']: !isNumberVariant,
['hidden']: isDotsVariant,
});
const { label, number } = getStepLabel(labelOrKey, index);
return (
<Fragment key={index}>
<div aria-selected={selected} className={className}>
<span className={labelClassName}>
{number}
<If condition={!isNumberVariant}>. {label}</If>
</span>
</div>
<If condition={isNumberVariant}>
<StepDivider selected={selected} complete={complete}>
{label}
</StepDivider>
</If>
</Fragment>
);
});
}
/**
* Renders a stepper component with multiple steps.
*
@@ -27,46 +75,6 @@ export function Stepper(props: {
}) {
const variant = props.variant ?? 'default';
const Steps = useCallback(() => {
return props.steps.map((labelOrKey, index) => {
const selected = props.currentStep === index;
const complete = props.currentStep > index;
const className = classNameBuilder({
selected,
variant,
complete,
});
const isNumberVariant = variant === 'numbers';
const isDotsVariant = variant === 'dots';
const labelClassName = cn({
['px-1.5 py-2 text-xs']: !isNumberVariant,
['hidden']: isDotsVariant,
});
const { label, number } = getStepLabel(labelOrKey, index);
return (
<Fragment key={index}>
<div aria-selected={selected} className={className}>
<span className={labelClassName}>
{number}
<If condition={!isNumberVariant}>. {label}</If>
</span>
</div>
<If condition={isNumberVariant}>
<StepDivider selected={selected} complete={complete}>
{label}
</StepDivider>
</If>
</Fragment>
);
});
}, [props.steps, props.currentStep, variant]);
// If there are no steps, don't render anything.
if (props.steps.length < 2) {
return null;
@@ -75,12 +83,16 @@ export function Stepper(props: {
const containerClassName = cn('w-full', {
['flex justify-between']: variant === 'numbers',
['flex space-x-0.5']: variant === 'default',
['flex gap-x-4 self-center']: variant === 'dots',
['flex space-x-2.5 self-center']: variant === 'dots',
});
return (
<div className={containerClassName}>
<Steps />
<Steps
steps={props.steps}
currentStep={props.currentStep}
variant={variant}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { RocketIcon } from 'lucide-react';
@@ -38,12 +38,10 @@ export function VersionUpdater(props: { intervalTimeInSecond?: number }) {
const [dismissed, setDismissed] = useState(false);
const [showDialog, setShowDialog] = useState<boolean>(false);
useEffect(() => {
setShowDialog(data?.didChange ?? false);
}, [data?.didChange]);
if (!data?.didChange || dismissed) {
return null;
} else {
setShowDialog(data?.didChange ?? false);
}
return (

View File

@@ -677,6 +677,7 @@ const SidebarMenuSkeleton: React.FC<
> = ({ className, showIcon = false, ...props }) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
// eslint-disable-next-line react-hooks/purity
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);