chore(dependencies): update libraries and added File Uploader component (#292)

- Bumped dependencies: `lucide-react`, `react-hook-form`, `@supabase/supabase-js`, `@tanstack/react-query`, `@sentry/nextjs`, and more.
- Added `react-dropzone` to `@kit/ui` for file upload support.
- Adjusted `reset-password.html` to streamline style usage and HTML structure.
- Added new translation keys for file upload functionality.
- Cleaned up import order in `existing-account-hint.tsx`.
This commit is contained in:
Giancarlo Buomprisco
2025-06-26 12:40:54 +07:00
committed by GitHub
parent 180e0e0c5e
commit c1fda420e6
44 changed files with 2454 additions and 2321 deletions

View File

@@ -16,7 +16,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "^24.0.1"
"@types/node": "^24.0.4"
},
"typesVersions": {
"*": {

View File

@@ -26,13 +26,13 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.50.0",
"@supabase/supabase-js": "2.50.2",
"@types/react": "19.1.8",
"date-fns": "^4.1.0",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"lucide-react": "^0.523.0",
"next": "15.3.4",
"react": "19.1.0",
"react-hook-form": "^7.58.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.3",
"zod": "^3.25.67"
},

View File

@@ -25,7 +25,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.8",
"next": "15.3.3",
"next": "15.3.4",
"react": "19.1.0",
"zod": "^3.25.67"
},

View File

@@ -16,7 +16,7 @@
},
"dependencies": {
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"@stripe/stripe-js": "^7.4.0",
"stripe": "^18.2.1"
},
"devDependencies": {
@@ -29,7 +29,7 @@
"@kit/ui": "workspace:*",
"@types/react": "19.1.8",
"date-fns": "^4.1.0",
"next": "15.3.3",
"next": "15.3.4",
"react": "19.1.0",
"zod": "^3.25.67"
},

View File

@@ -20,7 +20,7 @@
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*",
"@types/node": "^24.0.1"
"@types/node": "^24.0.4"
},
"typesVersions": {
"*": {

View File

@@ -26,7 +26,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^24.0.1",
"@types/node": "^24.0.4",
"@types/react": "19.1.8",
"react": "19.1.0",
"zod": "^3.25.67"

View File

@@ -20,7 +20,7 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^24.0.1",
"@types/node": "^24.0.4",
"@types/react": "19.1.8",
"wp-types": "^4.68.0"
},

View File

@@ -22,7 +22,7 @@
"@kit/supabase": "workspace:*",
"@kit/team-accounts": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.50.0",
"@supabase/supabase-js": "2.50.2",
"zod": "^3.25.67"
},
"typesVersions": {

View File

@@ -13,7 +13,7 @@
".": "./src/index.ts"
},
"dependencies": {
"@react-email/components": "0.1.0"
"@react-email/components": "0.1.1"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",

View File

@@ -34,16 +34,16 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"lucide-react": "^0.523.0",
"next": "15.3.4",
"next-themes": "0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.58.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.3",
"zod": "^3.25.67"
},

View File

@@ -95,7 +95,8 @@ export function PersonalAccountDropdown({
className ?? '',
{
['active:bg-secondary/50 items-center gap-4 rounded-md' +
' hover:bg-secondary p-2 transition-colors border border-dashed']: showProfileName,
' hover:bg-secondary border border-dashed p-2 transition-colors']:
showProfileName,
},
)}
>

View File

@@ -20,15 +20,15 @@
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.8",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"lucide-react": "^0.523.0",
"next": "15.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.58.0",
"react-hook-form": "^7.58.1",
"zod": "^3.25.67"
},
"exports": {

View File

@@ -29,12 +29,12 @@
"@kit/ui": "workspace:*",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@types/react": "19.1.8",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"react-hook-form": "^7.58.0",
"lucide-react": "^0.523.0",
"next": "15.3.4",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.3",
"sonner": "^2.0.5",
"zod": "^3.25.67"

View File

@@ -7,13 +7,13 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { UserCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { useTranslation } from 'react-i18next';
interface ExistingAccountHintProps {
signInPath?: string;

View File

@@ -62,7 +62,6 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
<span>
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
<If condition={isOAuth && Boolean(providerName)}>
<Trans
i18nKey="auth:methodOauthWithProvider"
@@ -72,7 +71,6 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
}}
/>
</If>
<If condition={!isOAuth || !providerName}>
<span className="text-muted-foreground font-medium">
<Trans i18nKey={methodKey} />

View File

@@ -19,10 +19,10 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@types/react": "19.1.8",
"lucide-react": "^0.516.0",
"lucide-react": "^0.523.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.5.3"

View File

@@ -32,18 +32,18 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.516.0",
"next": "15.3.3",
"lucide-react": "^0.523.0",
"next": "15.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.58.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.3",
"zod": "^3.25.67"
},

View File

@@ -20,8 +20,8 @@
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@tanstack/react-query": "5.80.7",
"next": "15.3.3",
"@tanstack/react-query": "5.81.2",
"next": "15.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.5.3"

View File

@@ -20,7 +20,7 @@
"@kit/resend": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "^24.0.1",
"@types/node": "^24.0.4",
"zod": "^3.25.67"
},
"typesVersions": {

View File

@@ -17,7 +17,7 @@
"@kit/mailers-shared": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "^24.0.1",
"@types/node": "^24.0.4",
"zod": "^3.25.67"
},
"typesVersions": {

View File

@@ -16,8 +16,8 @@
"./config/server": "./src/sentry.client.server.ts"
},
"dependencies": {
"@sentry/nextjs": "^9.29.0",
"import-in-the-middle": "1.14.0"
"@sentry/nextjs": "^9.32.0",
"import-in-the-middle": "1.14.2"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",

View File

@@ -20,8 +20,8 @@
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.50.0",
"next": "15.3.3",
"@supabase/supabase-js": "2.50.2",
"next": "15.3.4",
"zod": "^3.25.67"
},
"typesVersions": {

View File

@@ -25,12 +25,12 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0",
"@supabase/supabase-js": "2.50.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.58.0",
"react-hook-form": "^7.58.1",
"zod": "^3.25.67"
},
"typesVersions": {

View File

@@ -25,10 +25,10 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@types/react": "19.1.8",
"next": "15.3.3",
"next": "15.3.4",
"react": "19.1.0",
"server-only": "^0.0.1",
"zod": "^3.25.67"

View File

@@ -33,7 +33,8 @@
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"input-otp": "1.4.2",
"lucide-react": "^0.516.0",
"lucide-react": "^0.523.0",
"react-dropzone": "^14.3.8",
"react-top-loading-bar": "3.0.2",
"recharts": "2.15.3",
"tailwind-merge": "^3.3.1"
@@ -43,18 +44,19 @@
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@tanstack/react-query": "5.80.7",
"@supabase/supabase-js": "2.50.2",
"@tanstack/react-query": "5.81.2",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"eslint": "^9.29.0",
"next": "15.3.3",
"next": "15.3.4",
"next-themes": "0.4.6",
"prettier": "^3.5.3",
"prettier": "^3.6.1",
"react-day-picker": "^9.7.0",
"react-hook-form": "^7.58.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.3",
"sonner": "^2.0.5",
"tailwindcss": "4.1.10",
@@ -130,7 +132,8 @@
"./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx",
"./empty-state": "./src/makerkit/empty-state.tsx",
"./marketing": "./src/makerkit/marketing/index.tsx",
"./oauth-provider-logo-image": "./src/makerkit/oauth-provider-logo-image.tsx"
"./oauth-provider-logo-image": "./src/makerkit/oauth-provider-logo-image.tsx",
"./file-uploader": "./src/makerkit/file-uploader.tsx"
},
"typesVersions": {
"*": {

View File

@@ -0,0 +1,247 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import {
type FileError,
type FileRejection,
useDropzone,
} from 'react-dropzone';
interface FileWithPreview extends File {
preview?: string;
errors: readonly FileError[];
}
type UseSupabaseUploadOptions = {
/**
* Name of bucket to upload files to in your Supabase project
*/
bucketName: string;
/**
* Folder to upload files to in the specified bucket within your Supabase project.
*
* Defaults to uploading files to the root of the bucket
*
* e.g If specified path is `test`, your file will be uploaded as `test/file_name`
*/
path?: string;
/**
* Allowed MIME types for each file upload (e.g `image/png`, `text/html`, etc). Wildcards are also supported (e.g `image/*`).
*
* Defaults to allowing uploading of all MIME types.
*/
allowedMimeTypes?: string[];
/**
* Maximum upload size of each file allowed in bytes. (e.g 1000 bytes = 1 KB)
*/
maxFileSize?: number;
/**
* Maximum number of files allowed per upload.
*/
maxFiles?: number;
/**
* The number of seconds the asset is cached in the browser and in the Supabase CDN.
*
* This is set in the Cache-Control: max-age=<seconds> header. Defaults to 3600 seconds.
*/
cacheControl?: number;
/**
* When set to true, the file is overwritten if it exists.
*
* When set to false, an error is thrown if the object already exists. Defaults to `false`
*/
upsert?: boolean;
/**
* Supabase client to use for the upload.
*/
client: SupabaseClient;
/**
* Callback to call when the upload is successful.
*/
onUploadSuccess?: (files: string[]) => void;
};
export type UseSupabaseUploadReturn = ReturnType<typeof useSupabaseUpload>;
/**
* Hook to upload files to a Supabase bucket.
*
* @param options - Options for the upload.
* @returns The upload state.
*/
export const useSupabaseUpload = (options: UseSupabaseUploadOptions) => {
const {
bucketName,
path,
allowedMimeTypes = [],
maxFileSize = Number.POSITIVE_INFINITY,
maxFiles = 1,
cacheControl = 3600,
upsert = false,
client,
onUploadSuccess,
} = options;
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [errors, setErrors] = useState<{ name: string; message: string }[]>([]);
const [successes, setSuccesses] = useState<string[]>([]);
const isSuccess = useMemo(() => {
if (errors.length === 0 && successes.length === 0) {
return false;
}
if (errors.length === 0 && successes.length === files.length) {
return true;
}
return false;
}, [errors.length, successes.length, files.length]);
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
const validFiles = acceptedFiles
.filter((file) => !files.find((x) => x.name === file.name))
.map((file) => {
(file as FileWithPreview).preview = URL.createObjectURL(file);
(file as FileWithPreview).errors = [];
return file as FileWithPreview;
});
const invalidFiles = fileRejections.map(({ file, errors }) => {
(file as FileWithPreview).preview = URL.createObjectURL(file);
(file as FileWithPreview).errors = errors;
return file as FileWithPreview;
});
const newFiles = [...files, ...validFiles, ...invalidFiles];
setFiles(newFiles);
},
[files, setFiles],
);
const dropzoneProps = useDropzone({
onDrop,
noClick: true,
accept: allowedMimeTypes.reduce(
(acc, type) => ({ ...acc, [type]: [] }),
{},
),
maxSize: maxFileSize,
maxFiles: maxFiles,
multiple: maxFiles !== 1,
});
const onUpload = useCallback(async () => {
setLoading(true);
// [Joshen] This is to support handling partial successes
// If any files didn't upload for any reason, hitting "Upload" again will only upload the files that had errors
const filesWithErrors = errors.map((x) => x.name);
const filesToUpload =
filesWithErrors.length > 0
? [
...files.filter((f) => filesWithErrors.includes(f.name)),
...files.filter((f) => !successes.includes(f.name)),
]
: files;
const responses = await Promise.all(
filesToUpload.map(async (file) => {
const filePath = path ? `${path}/${file.name}` : file.name;
const { error } = await client.storage
.from(bucketName)
.upload(filePath, file, {
cacheControl: cacheControl.toString(),
upsert,
});
const fullFilePath = [bucketName, filePath].join('/');
if (error) {
return { name: file.name, message: error.message, fullFilePath };
} else {
return { name: file.name, message: undefined, fullFilePath };
}
}),
);
const responseErrors = responses.filter((x) => x.message !== undefined);
// if there were errors previously, this function tried to upload the files again so we should clear/overwrite the existing errors.
setErrors(responseErrors);
const responseSuccesses = responses.filter((x) => x.message === undefined);
const newSuccesses = Array.from(
new Set([...successes, ...responseSuccesses.map((x) => x.name)]),
);
setSuccesses(newSuccesses);
if (responseSuccesses.length > 0) {
const files = responseSuccesses.map((item) => {
return item.fullFilePath;
});
onUploadSuccess?.(files);
}
setLoading(false);
}, [
files,
path,
bucketName,
errors,
successes,
onUploadSuccess,
client,
cacheControl,
upsert,
]);
useEffect(() => {
if (files.length === 0) {
setErrors([]);
}
// If the number of files doesn't exceed the maxFiles parameter, remove the error 'Too many files' from each file
if (files.length <= maxFiles) {
let changed = false;
const newFiles = files.map((file) => {
if (file.errors.some((e) => e.code === 'too-many-files')) {
file.errors = file.errors.filter((e) => e.code !== 'too-many-files');
changed = true;
}
return file;
});
if (changed) {
setFiles(newFiles);
}
}
}, [files.length, setFiles, maxFiles, files]);
return {
files,
setFiles,
successes,
isSuccess,
loading,
errors,
setErrors,
onUpload,
maxFileSize: maxFileSize,
maxFiles: maxFiles,
allowedMimeTypes,
...dropzoneProps,
};
};

View File

@@ -0,0 +1,306 @@
'use client';
import {
type PropsWithChildren,
createContext,
useCallback,
useContext,
} from 'react';
import { CheckCircle, File, Loader2, Upload, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { type UseSupabaseUploadReturn } from '../hooks/use-supabase-upload';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
import { Trans } from './trans';
export const formatBytes = (
bytes: number,
decimals = 2,
size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB',
) => {
const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
if (bytes === 0 || bytes === undefined) {
return size !== undefined ? `0 ${size}` : '0 bytes';
}
const i =
size !== undefined
? sizes.indexOf(size)
: Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
type DropzoneContextType = Omit<
UseSupabaseUploadReturn,
'getRootProps' | 'getInputProps'
>;
const DropzoneContext = createContext<DropzoneContextType | undefined>(
undefined,
);
type DropzoneProps = UseSupabaseUploadReturn & {
className?: string;
};
const Dropzone = ({
className,
children,
getRootProps,
getInputProps,
...restProps
}: PropsWithChildren<DropzoneProps>) => {
const isSuccess = restProps.isSuccess;
const isActive = restProps.isDragActive;
const isInvalid =
(restProps.isDragActive && restProps.isDragReject) ||
(restProps.errors.length > 0 && !restProps.isSuccess) ||
restProps.files.some((file) => file.errors.length !== 0);
return (
<DropzoneContext.Provider value={{ ...restProps }}>
<div
{...getRootProps({
className: cn(
'bg-card text-foreground rounded-lg border p-6 text-center transition-colors duration-300',
className,
isSuccess ? 'border-solid' : 'border-dashed',
isActive && 'border-primary',
isInvalid && 'border-destructive bg-destructive/10',
),
})}
>
<input {...getInputProps()} />
{children}
</div>
</DropzoneContext.Provider>
);
};
const DropzoneContent = ({ className }: { className?: string }) => {
const {
files,
setFiles,
onUpload,
loading,
successes,
errors,
maxFileSize,
maxFiles,
isSuccess,
} = useDropzoneContext();
const { t } = useTranslation();
const exceedMaxFiles = files.length > maxFiles;
const handleRemoveFile = useCallback(
(fileName: string) => {
setFiles(files.filter((file) => file.name !== fileName));
},
[files, setFiles],
);
if (isSuccess) {
return (
<div
className={cn(
'flex flex-row items-center justify-center gap-x-2',
className,
)}
>
<CheckCircle size={16} className="text-primary" />
<p className="text-primary text-sm">
<Trans
i18nKey="common:dropzone.success"
values={{ count: files.length }}
/>
</p>
</div>
);
}
return (
<div className={cn('flex flex-col', className)}>
{files.map((file, idx) => {
const fileError = errors.find((e) => e.name === file.name);
const isSuccessfullyUploaded = !!successes.find((e) => e === file.name);
return (
<div
key={`${file.name}-${idx}`}
className="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4"
>
{file.type.startsWith('image/') ? (
<div className="bg-muted flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
decoding={'async'}
src={file.preview}
alt={file.name}
className="object-cover"
/>
</div>
) : (
<div className="bg-muted flex h-10 w-10 items-center justify-center rounded border">
<File size={18} />
</div>
)}
<div className="flex shrink grow flex-col items-start truncate">
<p title={file.name} className="max-w-full truncate text-sm">
{file.name}
</p>
{file.errors.length > 0 ? (
<p className="text-destructive text-xs">
{file.errors
.map((e) =>
e.message.startsWith('File is larger than')
? t('common:dropzone.errorMessageFileSizeTooLarge', {
size: formatBytes(file.size, 2),
maxSize: formatBytes(maxFileSize, 2),
})
: e.message,
)
.join(', ')}
</p>
) : loading && !isSuccessfullyUploaded ? (
<p className="text-muted-foreground text-xs">
<Trans i18nKey="common:dropzone.uploading" />
</p>
) : fileError ? (
<p className="text-destructive text-xs">
<Trans
i18nKey="common:dropzone.errorMessage"
values={{ message: fileError.message }}
/>
</p>
) : isSuccessfullyUploaded ? (
<p className="text-primary text-xs">
<Trans i18nKey="common:dropzone.success" />
</p>
) : (
<p className="text-muted-foreground text-xs">
{formatBytes(file.size, 2)}
</p>
)}
</div>
{!loading && !isSuccessfullyUploaded && (
<Button
size="icon"
variant="link"
className="text-muted-foreground hover:text-foreground shrink-0 justify-self-end"
onClick={() => handleRemoveFile(file.name)}
>
<X />
</Button>
)}
</div>
);
})}
{exceedMaxFiles && (
<p className="text-destructive mt-2 text-left text-sm">
<Trans
i18nKey="common:dropzone.errorMaxFiles"
values={{ count: maxFiles, files: files.length - maxFiles }}
/>
</p>
)}
{files.length > 0 && !exceedMaxFiles && (
<div className="mt-2">
<Button
variant="outline"
onClick={onUpload}
disabled={files.some((file) => file.errors.length !== 0) || loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Trans i18nKey="common:dropzone.uploading" />
</>
) : (
<span className="flex items-center">
<Upload size={20} className="mr-2 h-4 w-4" />
<Trans
i18nKey="common:dropzone.uploadFiles"
values={{
count: files.length,
}}
/>
</span>
)}
</Button>
</div>
)}
</div>
);
};
const DropzoneEmptyState = ({ className }: { className?: string }) => {
const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext();
if (isSuccess) {
return null;
}
return (
<div className={cn('flex flex-col items-center gap-y-2', className)}>
<Upload size={20} className="text-muted-foreground" />
<p className="text-sm">
<Trans
i18nKey="common:dropzone.uploadFiles"
values={{ count: maxFiles }}
/>
</p>
<div className="flex flex-col items-center gap-y-1">
<p className="text-muted-foreground text-xs">
<Trans i18nKey="common:dropzone.dragAndDrop" />{' '}
<a
onClick={() => inputRef.current?.click()}
className="hover:text-foreground cursor-pointer underline transition"
>
<Trans
i18nKey="common:dropzone.select"
values={{ count: maxFiles === 1 ? `file` : 'files' }}
/>
</a>{' '}
<Trans i18nKey="common:dropzone.toUpload" />
</p>
{maxFileSize !== Number.POSITIVE_INFINITY && (
<p className="text-muted-foreground text-xs">
<Trans
i18nKey="common:dropzone.maxFileSize"
values={{ size: formatBytes(maxFileSize, 2) }}
/>
</p>
)}
</div>
</div>
);
};
const useDropzoneContext = () => {
const context = useContext(DropzoneContext);
if (!context) {
throw new Error('useDropzoneContext must be used within a Dropzone');
}
return context;
};
export { Dropzone, DropzoneContent, DropzoneEmptyState, useDropzoneContext };

View File

@@ -0,0 +1,28 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { useSupabaseUpload } from '../hooks/use-supabase-upload';
import { cn } from '../lib/utils/cn';
import { Dropzone, DropzoneContent, DropzoneEmptyState } from './dropzone';
export const FileUploader = (props: {
className?: string;
maxFiles: number;
bucketName: string;
path?: string;
allowedMimeTypes: string[];
maxFileSize: number | undefined;
cacheControl?: number;
client: SupabaseClient;
onUploadSuccess?: (files: string[]) => void;
}) => {
const uploader = useSupabaseUpload(props);
return (
<div className={cn(props.className)}>
<Dropzone {...uploader}>
<DropzoneEmptyState />
<DropzoneContent />
</Dropzone>
</div>
);
};