Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
0fe3fbe
fix: make dropdown menus scrollable
imdeaconu Sep 6, 2024
4ca1a44
fix: truncate overflowing table columns
imdeaconu Sep 6, 2024
921aa13
Merge branch 'commitglobal:main' into main
imdeaconu Sep 6, 2024
e5a2869
Merge branch 'commitglobal:main' into main
imdeaconu Sep 6, 2024
7866a67
Merge branch 'commitglobal:main' into main
imdeaconu Sep 9, 2024
9ea6c42
Merge branch 'commitglobal:main' into main
imdeaconu Sep 10, 2024
1bd449d
Merge branch 'commitglobal:main' into main
imdeaconu Sep 11, 2024
c9874d1
Squashed commit of the following:
imdeaconu Sep 11, 2024
b7715f3
Merge branch 'commitglobal:main' into main
imdeaconu Sep 12, 2024
0facf65
Squashed commit of the following:
imdeaconu Sep 13, 2024
67f681d
chore: remove unused import
imdeaconu Sep 13, 2024
8d73252
chore: delete duplicated / unused classes
imdeaconu Sep 16, 2024
63b21b9
Merge branch 'commitglobal:main' into main
imdeaconu Sep 17, 2024
1892e6e
Merge branch 'commitglobal:main' into main
imdeaconu Sep 18, 2024
abb7c01
feature: add searching to MonitoringObserversTagFilter
imdeaconu Sep 19, 2024
9d0b8ae
Merge branch 'commitglobal:main' into main
imdeaconu Sep 20, 2024
c9fcd3e
chore: update config files
imdeaconu Sep 20, 2024
333ba49
Revert "[NGO Admin] Rewrite the tag selector component (#675)"
imdeaconu Sep 23, 2024
580b68e
Merge branch 'main' of https://github.com/commitglobal/votemonitor
imdeaconu Sep 23, 2024
ba2dad9
Merge branch 'commitglobal:main' into main
imdeaconu Sep 25, 2024
eea4faa
Merge branch 'main' of https://github.com/commitglobal/votemonitor in…
imdeaconu Sep 26, 2024
29b8163
Merge branch 'main' of https://github.com/commitglobal/votemonitor in…
imdeaconu Sep 26, 2024
68a44ee
Merge branch 'commitglobal-main'
imdeaconu Sep 26, 2024
7cf3244
Merge branch 'main' of https://github.com/commitglobal/votemonitor in…
imdeaconu Oct 1, 2024
b6abee7
Merge branch 'commitglobal-main-s1'
imdeaconu Oct 1, 2024
cc71856
Merge branch 'commitglobal:main' into main
imdeaconu Oct 2, 2024
e45ea22
Merge branch 'commitglobal:main' into main
imdeaconu Oct 2, 2024
50d15b6
Merge branch 'commitglobal:main' into main
imdeaconu Oct 2, 2024
1ed8e99
Merge branch 'commitglobal:main' into main
imdeaconu Oct 3, 2024
c2f1395
Merge branch 'commitglobal:main' into main
imdeaconu Oct 7, 2024
2c6d5f0
Merge branch 'commitglobal:main' into main
imdeaconu Oct 9, 2024
8c8e18f
Merge branch 'commitglobal:main' into main
imdeaconu Oct 9, 2024
db46a6d
Merge branch 'commitglobal:main' into main
imdeaconu Oct 10, 2024
d4b0263
Merge branch 'commitglobal:main' into main
imdeaconu Oct 12, 2024
79864cd
Merge branch 'commitglobal:main' into main
imdeaconu Oct 14, 2024
5ae7edf
Merge branch 'commitglobal:main' into main
imdeaconu Oct 15, 2024
c0fe98a
Merge branch 'commitglobal:main' into main
imdeaconu Oct 17, 2024
b7b3c5c
Merge branch 'commitglobal:main' into main
imdeaconu Oct 18, 2024
a892349
Merge branch 'commitglobal:main' into main
imdeaconu Oct 19, 2024
2d98793
Merge branch 'commitglobal:main' into main
imdeaconu Oct 20, 2024
66ba8d0
Merge branch 'commitglobal:main' into main
imdeaconu Oct 21, 2024
a86608d
Merge branch 'commitglobal:main' into main
imdeaconu Oct 22, 2024
aa1745c
Merge branch 'commitglobal:main' into main
imdeaconu Oct 23, 2024
9942029
Merge branch 'commitglobal:main' into main
imdeaconu Oct 23, 2024
d855c24
Merge branch 'commitglobal:main' into main
imdeaconu Oct 25, 2024
5a2b99d
Merge branch 'commitglobal:main' into main
imdeaconu Oct 28, 2024
e9ea9a3
Merge branch 'commitglobal:main' into main
imdeaconu Oct 29, 2024
fdaba4b
Merge branch 'commitglobal:main' into main
imdeaconu Oct 29, 2024
777ab43
Merge branch 'commitglobal:main' into main
imdeaconu Nov 4, 2024
15101d6
Merge branch 'commitglobal:main' into main
imdeaconu Nov 6, 2024
9289b11
Merge branch 'commitglobal:main' into main
imdeaconu Nov 28, 2024
a82ea16
Merge branch 'commitglobal:main' into main
imdeaconu Nov 30, 2024
b8814e5
feature: invalidate observers with duplicate emails on import
imdeaconu Dec 3, 2024
91dfa47
WIP: update file uploader UI
imdeaconu Dec 3, 2024
eba4557
WIP: add yoyșe & breadcrumbs to monitoring observer list importer
imdeaconu Dec 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 102 additions & 134 deletions web/src/components/ui/file-uploader.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { ArrowUpTrayIcon, DocumentTextIcon, XMarkIcon } from "@heroicons/react/24/solid"
import * as React from "react"
import Dropzone, {
type DropzoneProps,
type FileRejection,
} from "react-dropzone"
import { ArrowUpTrayIcon, DocumentTextIcon, XMarkIcon } from '@heroicons/react/24/solid';
import * as React from 'react';
import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone';

import { Button } from "@/components/ui/button"
import { useControllableState } from "@/components/ui/use-controllable-state"
import { cn, formatBytes } from "@/lib/utils"
import { toast } from "./use-toast"
import { Button } from '@/components/ui/button';
import { useControllableState } from '@/components/ui/use-controllable-state';
import { cn, formatBytes } from '@/lib/utils';
import { toast } from './use-toast';

interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
Expand All @@ -17,15 +14,15 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
* @default undefined
* @example value={files}
*/
value?: File[]
value?: File[];

/**
* Function to be called when the value changes.
* @type (files: File[]) => void
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange?: (files: File[]) => void
onValueChange?: (files: File[]) => void;

/**
* Accepted file types for the uploader.
Expand All @@ -36,224 +33,195 @@ interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
* ```
* @example accept={["image/png", "image/jpeg"]}
*/
accept?: DropzoneProps["accept"]
accept?: DropzoneProps['accept'];

/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 2 // 2MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps["maxSize"]
maxSize?: DropzoneProps['maxSize'];

/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFileCount={4}
*/
maxFileCount?: DropzoneProps["maxFiles"]
maxFileCount?: DropzoneProps['maxFiles'];

/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean
multiple?: boolean;

/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean
disabled?: boolean;
}

export function FileUploader(props: FileUploaderProps) {
const {
value: valueProp,
onValueChange,
accept = {
"image/*": [],
'image/*': [],
},
maxSize = 1024 * 1024 * 2,
maxFileCount = 1,
multiple = false,
disabled = false,
className,
...dropzoneProps
} = props
} = props;

const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange,
})
});

const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
toast({
title:"Cannot upload more than 1 file at a time",
variant: 'destructive'
})
return
title: 'Cannot upload more than 1 file at a time',
variant: 'destructive',
});
return;
}

if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
toast({title:`Cannot upload more than ${maxFileCount} files`, variant: 'destructive'})
return
toast({ title: `Cannot upload more than ${maxFileCount} files`, variant: 'destructive' });
return;
}

const newFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
)
);

const updatedFiles = files ? [...files, ...newFiles] : newFiles
const updatedFiles = files ? [...files, ...newFiles] : newFiles;

setFiles(updatedFiles)
setFiles(updatedFiles);

if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast({title: `File ${file.name} was rejected`, variant: 'destructive'})
})
toast({ title: `File ${file.name} was rejected`, variant: 'destructive' });
});
}
},

[files, maxFileCount, multiple, setFiles]
)
);

function onRemove(index: number) {
if (!files) return
const newFiles = files.filter((_, i) => i !== index)
setFiles(newFiles)
onValueChange?.(newFiles)
if (!files) return;
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
onValueChange?.(newFiles);
}

const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;

return (
<div className="relative flex flex-col gap-6 overflow-hidden">
{!files?.length || (files?.length ?? 0) < maxFileCount ?<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
"group relative grid h-32 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isDragActive && "border-muted-foreground/50",
isDisabled && "pointer-events-none opacity-60",
className
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="p-3 border border-dashed rounded-full">
<ArrowUpTrayIcon
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
</div>
<p className="font-medium text-muted-foreground">
Drop the files here
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="p-3 border border-dashed rounded-full">
<ArrowUpTrayIcon
className="size-7 text-muted-foreground"
aria-hidden="true"
/>
<div className='relative flex flex-col gap-6 overflow-hidden'>
{!files?.length || (files?.length ?? 0) < maxFileCount ? (
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'group relative grid w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-10 text-center transition hover:bg-muted/25',
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isDragActive && 'border-muted-foreground/50',
isDisabled && 'pointer-events-none opacity-60',
className
)}
{...dropzoneProps}>
<input {...getInputProps()} />
{isDragActive ? (
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
<div className='p-3 border border-dashed rounded-full'>
<ArrowUpTrayIcon className='size-7 text-muted-foreground' aria-hidden='true' />
</div>
<p className='font-medium text-muted-foreground'>Drop the files here</p>
</div>
<div className="flex flex-col gap-px">
<p className="font-medium text-muted-foreground">
Drag {`'n'`} drop files here, or click to select files
</p>
<p className="text-sm text-muted-foreground/70">
You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? "multiple" : maxFileCount}
) : (
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
<div className='p-3 border border-dashed rounded-full'>
<ArrowUpTrayIcon className='size-7 text-muted-foreground' aria-hidden='true' />
</div>
<div className='flex flex-col gap-0.5'>
<p className='font-medium text-muted-foreground'>Drag {`'n'`} drop files here to start uploading</p>
<p className='font-medium text-muted-foreground/80'>or</p>
<p className='text-primary-600 underline'>Click here to browse files</p>

<p className='text-sm text-muted-foreground/70'>
You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`}
</p>
: ` a file with ${formatBytes(maxSize)}`}
</p>
</div>
</div>
</div>
)}
</div>
)}
</Dropzone> : null}
)}
</div>
)}
</Dropzone>
) : null}
{files?.length ? (
<div className="flex flex-col gap-4 max-h-48">
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
/>
))}
</div>
<div className='flex flex-col gap-4 max-h-48'>
{files?.map((file, index) => <FileCard key={index} file={file} onRemove={() => onRemove(index)} />)}
</div>
) : null}
</div>
)
);
}

interface FileCardProps {
file: File
onRemove: () => void
file: File;
onRemove: () => void;
}

function FileCard({ file, onRemove }: FileCardProps) {
return (
<div className="relative flex items-center gap-2.5">
<div className="flex flex-1 gap-2.5">
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col gap-px">
<p className="text-sm font-medium line-clamp-1 text-foreground/80">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(file.size)}
</p>
<div className='relative flex items-center gap-2.5'>
<div className='flex flex-1 gap-2.5'>
<div className='flex flex-col w-full gap-2'>
<div className='flex flex-col gap-px'>
<p className='text-sm font-medium line-clamp-1 text-foreground/80'>{file.name}</p>
<p className='text-xs text-muted-foreground'>{formatBytes(file.size)}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="size-7"
onClick={onRemove}
>
<XMarkIcon className="size-4" aria-hidden="true" />
<span className="sr-only">Remove file</span>
<div className='flex items-center gap-2'>
<Button type='button' variant='outline' size='icon' className='size-7' onClick={onRemove}>
<XMarkIcon className='size-4' aria-hidden='true' />
<span className='sr-only'>Remove file</span>
</Button>
</div>
</div>
)
);
}

interface FilePreviewProps {
}
interface FilePreviewProps {}

function FilePreview(props: FilePreviewProps) {
return (
<DocumentTextIcon
className="size-10 text-muted-foreground"
aria-hidden="true"
/>
)
return <DocumentTextIcon className='size-10 text-muted-foreground' aria-hidden='true' />;
}
Loading