Skip to content

Commit

Permalink
Feat/file input modal (#89)
Browse files Browse the repository at this point in the history
* wip: resource creation

* feat: add file type filtering, improve display

* feat: add better filenames display

* feat: add interactive drag & drop, add scroll]

* feat: add chunking, add aborts and timeouts

* feat: refactor
  • Loading branch information
DeutscherDude authored Jul 30, 2024
1 parent 5d3982c commit 41253ff
Show file tree
Hide file tree
Showing 21 changed files with 623 additions and 116 deletions.
4 changes: 3 additions & 1 deletion apps/frontend/.env.sample
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
VITE_API_BASE_URL=http://localhost:5000/
VITE_API_BASE_URL=http://localhost:5000/
VITE_MAX_FILE_SIZE=3865470566 # ~3.6GB
VITE_MAX_FILE_UPLOAD_TIMEOUT=180000
146 changes: 145 additions & 1 deletion apps/frontend/@/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ArrowUpOnSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid';
import * as React from 'react';

import { ScrollArea } from './scroll-area';

import { cn } from '@/lib/utils';

export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
Expand All @@ -18,6 +21,147 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
);
});

export interface FileInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
fileName: string;
containerClassName?: string;
onFilesValueChange: (files: FileList) => void;
}

const FileInput = React.forwardRef<HTMLInputElement, FileInputProps>(
(
{ className, containerClassName, fileName, type, onFilesValueChange, ...props },
ref: React.Ref<HTMLInputElement | null>,
) => {
const filesNames = fileName.split(',');

const innerRef = React.useRef<HTMLInputElement | null>(null);

const containerRef = React.useRef<HTMLDivElement | null>(null);

const dragImageRef = React.useRef<HTMLDivElement | null>(null);

React.useImperativeHandle<HTMLInputElement | null, HTMLInputElement | null>(
ref,
(): HTMLInputElement | null => innerRef?.current,
[innerRef],
);

// todo: revisit types
const onClick = (): void => {
// eslint-disable-next-line
// @ts-ignore
if (ref?.current) {
// eslint-disable-next-line
// @ts-ignore
ref?.current.click();
}
};

const onDragOver = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();

if (dragImageRef.current) {
dragImageRef.current.classList.remove('drag-view-invisible');

dragImageRef.current.classList.add('drag-view-visible');
}
};

const onDragLeave = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();

if (dragImageRef.current) {
dragImageRef.current.classList.remove('drag-view-visible');

dragImageRef.current.classList.add('drag-view-invisible');
}
};

const onDrop = (e: React.DragEvent<HTMLDivElement>): void => {
e.preventDefault();

if (dragImageRef.current) {
dragImageRef.current.classList.remove('drag-view-visible');

dragImageRef.current.classList.add('drag-view-invisible');
}

if (innerRef.current) {
innerRef.current.files = e.dataTransfer.files;

onFilesValueChange(e.dataTransfer.files);
}
};

return (
<div
className={cn(
`flex
flex-row
has-[input:focus-visible]:ring-2
has-[input:focus-visible]:ring-ring
has-[input:focus-visible]:ring-offset-4
bg-[#D1D5DB]/20
rounded-md
border
border-input
ring-offset-background
h-32
w-60 sm:w-96
relative`,
containerClassName,
)}
ref={containerRef}
>
<input
type={type}
className={cn(
'hidden w-60 sm:w-96 h-32 px-3 py-2 rounded-md text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 bg-none bg-[unset] focus:border-none outline-none cursor-pointer',
className,
)}
ref={innerRef}
{...props}
/>
<div
onClick={onClick}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn('cursor-pointer w-full h-full pl-2 items-center flex justify-between')}
>
{/* todo: add scroll area here to display lots of files */}
<ScrollArea className={cn('w-full h-full rounded-md py-2')}>
<div className="flex flex-col gap-2">
{filesNames.map((name, index) => (
<p
key={`${index}-${name}`}
className="text-sm truncate"
>
{name}
</p>
))}
</div>
</ScrollArea>
<div className="absolute right-0 px-2">
<PlusCircleIcon className={cn('h-6 w-6 text-primary pointer-events-none')}></PlusCircleIcon>
</div>
</div>
<div
ref={dragImageRef}
className="drag-view-invisible absolute w-full h-full border rounded-md pointer-events-none"
>
<div className="flex w-full h-full gap-2 items-center justify-center">
<ArrowUpOnSquareIcon className="h-10 w-10 text-primary"></ArrowUpOnSquareIcon>
<p>Drop files here</p>
</div>
</div>
</div>
);
},
);

FileInput.displayName = 'FileInput';

Input.displayName = 'Input';

export { Input };
export { Input, FileInput };
20 changes: 20 additions & 0 deletions apps/frontend/@/components/ui/loadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type FC } from 'react';

import { cn } from '../../lib/utils';

export const LoadingSpinner: FC<{ className?: string }> = ({ className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('animate-spin', className)}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
36 changes: 17 additions & 19 deletions apps/frontend/@/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,44 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import * as React from 'react';

import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';

const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
));

ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;

const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
));

ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;

export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };
2 changes: 1 addition & 1 deletion apps/frontend/@/components/ui/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';

const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className={cn('h-[40rem] md:h-[60rem] rounded-md border p-2 overflow-auto relative w-full')}>
<div className={cn('h-[30rem] md:h-[40rem] rounded-md border p-2 overflow-auto relative w-full')}>
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
Expand Down
14 changes: 13 additions & 1 deletion apps/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
@tailwind components;
@tailwind utilities;

input[type='file'] {
opacity: 0;
}

.drag-view-visible {
@apply visible bg-green-300 bg-opacity-25;
}

.drag-view-invisible {
@apply hidden;
}

@layer base {
:root {
--background: 0 0% 100%;
Expand Down Expand Up @@ -77,7 +89,7 @@

* {
background-color: var(--background);
color: var(--primary-foreground)
color: var(--primary-foreground);
}

@media (prefers-color-scheme: light) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export const CreateBucketDialog = ({ dialogOpen, onOpenChange }: CreateBucketDia
bucketName: payload.bucketName.toLowerCase(),
});

// todo: add buckets invalidation

onOpenChange(false);

createBucketForm.reset();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

import {
Expand All @@ -16,12 +16,15 @@ import { Button } from '../../../../../@/components/ui/button';
import { useUserTokensStore } from '../../../core/stores/userTokens/userTokens';
import { useDeleteBucketMutation } from '../../api/admin/mutations/deleteBucketMutation/deleteBucketMutation';
import { adminFindBucketsQueryOptions } from '../../api/admin/queries/adminFindBuckets/adminFindBucketsQueryOptions';
import { BucketApiQueryKeys } from '../../api/bucketApiQueryKeys';

interface Props {
bucketName: string;
}

export const DeleteBucketDialog = ({ bucketName }: Props): JSX.Element => {
const queryClient = useQueryClient();

const accessToken = useUserTokensStore.getState().accessToken;

const [open, onOpenChange] = useState(false);
Expand All @@ -31,7 +34,9 @@ export const DeleteBucketDialog = ({ bucketName }: Props): JSX.Element => {
const { mutateAsync: deleteBucketMutation } = useDeleteBucketMutation({});

const { refetch: refetchBuckets } = useQuery({
...adminFindBucketsQueryOptions(accessToken as string),
...adminFindBucketsQueryOptions({
accessToken: accessToken as string,
}),
});

const onDeleteBucket = async (bucketName: string): Promise<void> => {
Expand All @@ -43,6 +48,10 @@ export const DeleteBucketDialog = ({ bucketName }: Props): JSX.Element => {
onOpenChange(false);

refetchBuckets();

await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === BucketApiQueryKeys.adminFindBuckets,
});
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type RequestPayload = {
* Currently only supports 'application/json' and 'text/plain'.
*/
body?: Record<string, unknown>;
type?: 'json' | 'octet-stream';
signal?: AbortSignal;
};

type GetRequestPayload = Omit<RequestPayload, 'body'>;
Expand Down Expand Up @@ -75,16 +77,41 @@ export class HttpService {
}

public static async post<T = unknown>(payload: RequestPayload): Promise<HttpResponse<T>> {
const { url, headers, body } = payload;
const { url, headers, body, type = 'json', signal } = payload;

let requestBody: unknown;

let contentType = '';

if (type === 'json') {
requestBody = JSON.stringify(body);

contentType = 'application/json';
} else if (type === 'octet-stream') {
const formData = new FormData();

for (const file of body as unknown as File[]) {
formData.append('attachedFiles', file as unknown as Blob, file.name);
}

requestBody = formData;

contentType = '';
}

const response = await fetch(`${this.baseUrl}${url}`, {
headers: {
...headers,
Accept: 'application/json',
'Content-Type': 'application/json',
...(contentType
? {
'Content-Type': contentType,
}
: {}),
},
method: 'POST',
body: JSON.stringify(body),
body: requestBody as BodyInit,
signal,
});

const responseBodyText = await response.text();
Expand Down
Loading

0 comments on commit 41253ff

Please # to comment.