Files
myeasycms-v2/packages/ui/src/makerkit/dropzone.tsx
Giancarlo Buomprisco c1fda420e6 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`.
2025-06-26 13:40:54 +08:00

307 lines
8.9 KiB
TypeScript

'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 };