Refactor frontend components and API interactions
Removed redundant API endpoint and added reusable hooks. Implemented various UI component updates including loading spinner, file upload form, and machine selector. Improved state management in page and layout components and introduced new request handling functionalities.
This commit is contained in:
parent
ee127f431c
commit
bfe49f65ec
@ -1,3 +0,0 @@
|
||||
export async function GET(request: Request) {
|
||||
return new Response("Hello, from API!");
|
||||
}
|
@ -8,17 +8,19 @@ export const metadata = {
|
||||
description: 'Generated by create-nx-workspace',
|
||||
};
|
||||
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={"h-screen w-screen bg-card flex flex-col justify-between items-center police-ubuntu"}>
|
||||
<Header/>
|
||||
{children}
|
||||
<Footer/>
|
||||
<Header/>
|
||||
{children}
|
||||
<Footer/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
@ -6,30 +6,25 @@ import {
|
||||
ResizablePanelGroup
|
||||
} from 'apps/frontend/src/components/ui/resizable';
|
||||
import { ESubPage, SubPage, SubPageSelector } from '../components/sub-pages';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
const queryClient = new QueryClient({})
|
||||
|
||||
export default function HomePage() {
|
||||
const [currentSubPage, setCurrentSubPage] = useState<ESubPage>(0)
|
||||
return (
|
||||
<main className="w-full h-full bg-background border border-muted p-2 rounded-md flex flex-row justify-stretch items-stretch">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal">
|
||||
<ResizablePanel
|
||||
defaultSize={20}
|
||||
minSize={15}
|
||||
maxSize={25}
|
||||
className={"w-1/5 h-full p-1 flex flex-col items-center"}>
|
||||
<SubPageSelector
|
||||
currentSubPage={currentSubPage}
|
||||
setCurrentSubPage={setCurrentSubPage}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel
|
||||
className={"w-full flex justify-center items-center m-3"}>
|
||||
<SubPage currentSubPage={currentSubPage}/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</main>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<main
|
||||
className="w-full h-full bg-background border border-muted p-2 rounded-md flex flex-row justify-stretch items-stretch">
|
||||
<div className={"h-full flex flex-col justify-start items-center"}>
|
||||
<SubPageSelector
|
||||
currentSubPage={currentSubPage}
|
||||
setCurrentSubPage={setCurrentSubPage}
|
||||
/>
|
||||
</div>
|
||||
<SubPage currentSubPage={currentSubPage} />
|
||||
</main>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
327
apps/frontend/src/components/file-uploader.tsx
Normal file
327
apps/frontend/src/components/file-uploader.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import Dropzone, { DropzoneProps, FileRejection } from 'react-dropzone';
|
||||
import { toast } from 'sonner';
|
||||
import React from 'react';
|
||||
import { FileTextIcon, UploadIcon } from 'lucide-react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Progress } from '@radix-ui/react-progress';
|
||||
import { Button } from './ui/button';
|
||||
import { formatBytes } from '../lib/utils';
|
||||
|
||||
|
||||
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Value of the uploader.
|
||||
* @type File[]
|
||||
* @default undefined
|
||||
* @example value={files}
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* Function to be called when files are uploaded.
|
||||
* @type (files: File[]) => Promise<void>
|
||||
* @default undefined
|
||||
* @example onUpload={(files) => uploadFiles(files)}
|
||||
*/
|
||||
onUpload?: (files: File[]) => Promise<void>
|
||||
|
||||
/**
|
||||
* Progress of the uploaded files.
|
||||
* @type Record<string, number> | undefined
|
||||
* @default undefined
|
||||
* @example progresses={{ "file1.png": 50 }}
|
||||
*/
|
||||
progresses?: Record<string, number>
|
||||
|
||||
/**
|
||||
* Accepted file types for the uploader.
|
||||
* @type { [key: string]: string[]}
|
||||
* @default
|
||||
* ```ts
|
||||
* { "image/*": [] }
|
||||
* ```
|
||||
* @example accept={["image/png", "image/jpeg"]}
|
||||
*/
|
||||
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"]
|
||||
|
||||
/**
|
||||
* Maximum number of files for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1
|
||||
* @example maxFileCount={4}
|
||||
*/
|
||||
maxFileCount?: DropzoneProps["maxFiles"]
|
||||
|
||||
/**
|
||||
* Whether the uploader should accept multiple files.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example multiple
|
||||
*/
|
||||
multiple?: boolean
|
||||
|
||||
/**
|
||||
* Whether the uploader is disabled.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example disabled
|
||||
*/
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function FileUploader(props: FileUploaderProps) {
|
||||
const {
|
||||
value: valueProp,
|
||||
onValueChange,
|
||||
onUpload,
|
||||
progresses,
|
||||
accept = {
|
||||
"image/*": [],
|
||||
},
|
||||
maxSize = 1024 * 1024 * 2,
|
||||
maxFileCount = 1,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
className,
|
||||
...dropzoneProps
|
||||
} = 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.error("Cannot upload more than 1 file at a time")
|
||||
return
|
||||
}
|
||||
|
||||
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
||||
toast.error(`Cannot upload more than ${maxFileCount} files`)
|
||||
return
|
||||
}
|
||||
|
||||
const newFiles = acceptedFiles.map((file) =>
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
})
|
||||
)
|
||||
|
||||
const updatedFiles = files ? [...files, ...newFiles] : newFiles
|
||||
|
||||
setFiles(updatedFiles)
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
rejectedFiles.forEach(({ file }) => {
|
||||
toast.error(`File ${file.name} was rejected`)
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
onUpload &&
|
||||
updatedFiles.length > 0 &&
|
||||
updatedFiles.length <= maxFileCount
|
||||
) {
|
||||
const target =
|
||||
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`
|
||||
|
||||
toast.promise(onUpload(updatedFiles), {
|
||||
loading: `Uploading ${target}...`,
|
||||
success: () => {
|
||||
setFiles([])
|
||||
return `${target} uploaded`
|
||||
},
|
||||
error: `Failed to upload ${target}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
[files, maxFileCount, multiple, onUpload, setFiles]
|
||||
)
|
||||
|
||||
function onRemove(index: number) {
|
||||
if (!files) return
|
||||
const newFiles = files.filter((_, i) => i !== index)
|
||||
setFiles(newFiles)
|
||||
onValueChange?.(newFiles)
|
||||
}
|
||||
|
||||
// Revoke preview url when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (!files) return
|
||||
files.forEach((file) => {
|
||||
if (isFileWithPreview(file)) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-6 overflow-hidden">
|
||||
<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-52 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="rounded-full border border-dashed p-3">
|
||||
<UploadIcon
|
||||
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="rounded-full border border-dashed p-3">
|
||||
<UploadIcon
|
||||
className="size-7 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</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}
|
||||
files (up to ${formatBytes(maxSize)} each)`
|
||||
: ` a file with ${formatBytes(maxSize)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
{files?.length ? (
|
||||
<ScrollArea className="h-fit w-full px-3">
|
||||
<div className="flex max-h-48 flex-col gap-4">
|
||||
{files?.map((file, index) => (
|
||||
<FileCard
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => onRemove(index)}
|
||||
progress={progresses?.[file.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: File
|
||||
onRemove: () => void
|
||||
progress?: number
|
||||
}
|
||||
|
||||
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||
return (
|
||||
<div className="relative flex items-center gap-2.5">
|
||||
<div className="flex flex-1 gap-2.5">
|
||||
{isFileWithPreview(file) ? <FilePreview file={file} /> : null}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-px">
|
||||
<p className="line-clamp-1 text-sm font-medium text-foreground/80">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{progress ? <Progress value={progress} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Cross2Icon className="size-4" aria-hidden="true" />
|
||||
<span className="sr-only">Remove file</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isFileWithPreview(file: File): file is File & { preview: string } {
|
||||
return "preview" in file && typeof file.preview === "string"
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File & { preview: string }
|
||||
}
|
||||
|
||||
function FilePreview({ file }: FilePreviewProps) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return (
|
||||
<Image
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
width={48}
|
||||
height={48}
|
||||
loading="lazy"
|
||||
className="aspect-square shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileTextIcon
|
||||
className="size-10 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
154
apps/frontend/src/components/forms/file-upload.tsx
Normal file
154
apps/frontend/src/components/forms/file-upload.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Button } from "../ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form"
|
||||
import { Input } from "../ui/input"
|
||||
import { FilesApi } from 'apps/frontend/src/requests/files';
|
||||
import {
|
||||
MultipleMachinesSelector
|
||||
} from 'apps/frontend/src/components/forms/machines-selector';
|
||||
import MultipleSelector, { Option } from 'apps/frontend/src/components/ui/multiple-selector';
|
||||
import { MachinesApi } from 'apps/frontend/src/requests/machines';
|
||||
import { Loader } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
async function getMachines(value: string): Promise<Option[]> {
|
||||
try {
|
||||
const machines = await MachinesApi.get.all();
|
||||
console.log(machines.length);
|
||||
|
||||
const filtered = machines.filter((machine) => machine.machineName && machine.machineName.toLowerCase().includes(value.toLowerCase()));
|
||||
console.log(filtered.length);
|
||||
|
||||
const mapped = filtered.map((machine) => ({
|
||||
label: machine.machineName,
|
||||
value: machine.id,
|
||||
}));
|
||||
|
||||
return mapped;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des machines:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const machinesSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
disable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const fileUploadSchema = z.object({
|
||||
fileName: z.string().min(2, {
|
||||
message: "Le nom du fichier doit faire au moins faire 2 carractères.",
|
||||
}).max(128, {
|
||||
message: "Le nom du fichier ne peux pas faire plus de 128 carractères."
|
||||
}),
|
||||
author: z.string().min(2, {
|
||||
message: "Votre pseudonyme doit faire au moins faire 2 carractères."
|
||||
}).max(64, {
|
||||
message: "Votre pseudonyme ne peux pas faire plus de 64 carractères."
|
||||
}),
|
||||
machinesId: z.array(machinesSchema).min(1, {
|
||||
message: "Vous devez indiqué au moins une machine."
|
||||
})
|
||||
})
|
||||
|
||||
export function FileUploadForm() {
|
||||
const executeUpload = FilesApi.post.upload;
|
||||
const form = useForm<z.infer<typeof fileUploadSchema>>({
|
||||
resolver: zodResolver(fileUploadSchema),
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof fileUploadSchema>) {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom du fichier</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Le nom qui sera affiché lors d'une recherche.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="machinesId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Associer à des machines</FormLabel>
|
||||
<FormControl>
|
||||
<MultipleSelector
|
||||
{...field}
|
||||
onSearch={async (value) => {
|
||||
const res = await getMachines(value);
|
||||
return res;
|
||||
}}
|
||||
triggerSearchOnFocus
|
||||
placeholder="Cliquez pour chercher."
|
||||
loadingIndicator={
|
||||
<div>
|
||||
<Loader className={"animate-spin h-4 w-4 mr-2"} />
|
||||
<p className="py-2 text-center text-lg leading-10 text-muted-foreground">Chargement...</p>
|
||||
</div>
|
||||
}
|
||||
emptyIndicator={
|
||||
<p
|
||||
className="w-full text-center text-lg leading-10 text-muted-foreground">
|
||||
Auccun résultats
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Machine(s) qui seront associé(s) à ce fichier.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="author"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Votre pseudonyme ou nom.</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Votre nom d'affichage qui sera lié au fichier.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
50
apps/frontend/src/components/forms/machines-selector.tsx
Normal file
50
apps/frontend/src/components/forms/machines-selector.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import MultipleSelector, { Option } from '../ui/multiple-selector';
|
||||
import { MachinesApi } from '../../requests/machines';
|
||||
import { Loader } from 'lucide-react';
|
||||
import IntrinsicAttributes = React.JSX.IntrinsicAttributes;
|
||||
|
||||
async function getMachines(value: string): Promise<Option[]> {
|
||||
try {
|
||||
const machines = await MachinesApi.get.all();
|
||||
console.log(machines.length);
|
||||
|
||||
const filtered = machines.filter((machine) => machine.machineName && machine.machineName.toLowerCase().includes(value.toLowerCase()));
|
||||
console.log(filtered.length);
|
||||
|
||||
const mapped = filtered.map((machine) => ({
|
||||
label: machine.machineName,
|
||||
value: machine.id,
|
||||
}));
|
||||
|
||||
return mapped;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des machines:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function MultipleMachinesSelector(fields: IntrinsicAttributes) {
|
||||
|
||||
return (<MultipleSelector
|
||||
onSearch={async (value) => {
|
||||
const res = await getMachines(value);
|
||||
return res;
|
||||
}}
|
||||
triggerSearchOnFocus
|
||||
placeholder="Cliquez pour chercher."
|
||||
loadingIndicator={
|
||||
<div>
|
||||
<Loader className={"animate-spin h-4 w-4 mr-2"} />
|
||||
<p className="py-2 text-center text-lg leading-10 text-muted-foreground">Chargement...</p>
|
||||
</div>
|
||||
}
|
||||
emptyIndicator={
|
||||
<p
|
||||
className="w-full text-center text-lg leading-10 text-muted-foreground">
|
||||
Auccun résultats
|
||||
</p>
|
||||
}
|
||||
/>);
|
||||
};
|
8
apps/frontend/src/components/loading-spinner.tsx
Normal file
8
apps/frontend/src/components/loading-spinner.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className={"flex justify-center items-center gap-2 text-xl"}><Loader
|
||||
className={"animate-spin w-10 h-10"} />Loading...</div>)
|
||||
}
|
@ -1,18 +1,20 @@
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent, DialogDescription,
|
||||
DialogHeader, DialogTitle,
|
||||
DialogContent, DialogHeader,
|
||||
DialogTrigger
|
||||
} from './ui/dialog';
|
||||
import { FileInput } from 'lucide-react';
|
||||
import { FileUploadForm } from 'apps/frontend/src/components/forms/file-upload';
|
||||
import {
|
||||
MultipleMachinesSelector
|
||||
} from 'apps/frontend/src/components/forms/machines-selector';
|
||||
|
||||
export interface NewFileModalProps {
|
||||
classname?: string;
|
||||
}
|
||||
|
||||
export function NewFileModal(props: NewFileModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
@ -22,13 +24,8 @@ export function NewFileModal(props: NewFileModalProps) {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajout d'un fichier</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete your account
|
||||
and remove your data from our servers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogHeader>Ajouter un fichier</DialogHeader>
|
||||
<FileUploadForm/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
@ -21,7 +21,7 @@ export enum ESubPage {
|
||||
|
||||
export function SubPageSelector(props: SubPageSelectorProps) {
|
||||
return (
|
||||
<div className={"w-full flex flex-col justify-center items-stretch pt-4 p-4 gap-2"}>
|
||||
<div className={"max-[20%]: flex flex-col justify-center items-stretch pt-4 p-4 gap-2"}>
|
||||
<Button
|
||||
onClick={()=>props.setCurrentSubPage(ESubPage.Home)}
|
||||
disabled={props.currentSubPage === ESubPage.Home}
|
||||
|
@ -3,6 +3,9 @@ import { HomeIcon } from 'lucide-react';
|
||||
import {
|
||||
FilesDataTable, filesTableColumns
|
||||
} from 'apps/frontend/src/components/tables/files-table';
|
||||
import {
|
||||
ISearchBarResult, SearchBar
|
||||
} from 'apps/frontend/src/components/tables/search-bar';
|
||||
|
||||
|
||||
export interface SubHomePageProps {
|
||||
@ -11,28 +14,22 @@ export interface SubHomePageProps {
|
||||
|
||||
export function SubHomePage(props: SubHomePageProps) {
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(false);
|
||||
const [searchField, setSearchField] = useState<ISearchBarResult>({value: ""})
|
||||
|
||||
return (<section className={"w-full h-full rounded bg-card flex flex-col"}>
|
||||
<div className={"flex flex-row justify-start items-center gap-2 m-2"}>
|
||||
<HomeIcon
|
||||
className={"w-8 h-8 text-secondary"}
|
||||
/>
|
||||
<h1 className={"text-2xl font-bold"}>Page principal</h1>
|
||||
<h1 className={"text-2xl font-bold"}>Accueil</h1>
|
||||
</div>
|
||||
<div className={"m-1 flex flex-col justify-start items-center w-5/6 self-center h-full"}>
|
||||
<FilesDataTable columns={filesTableColumns} data={[{
|
||||
"uuid": "bbc17f8c-244d-4a44-8faf-c5e1ec0786bf",
|
||||
"fileName": "test",
|
||||
"checksum": "60d6473dc75edd2e885cc32c098f0379a5dd2d8175de0df1ef7526636b2a03f5",
|
||||
"extension": "jpeg",
|
||||
"groupId": null,
|
||||
"fileSize": 483636,
|
||||
"fileType": "2c1fb8eb-59b1-4bef-b50d-6bc854f46105",
|
||||
"isRestricted": false,
|
||||
"isDocumentation": false,
|
||||
"uploadedAt": "2024-10-21T11:40:36.350Z",
|
||||
"uploadedBy": "Avnyr"
|
||||
}]}/>
|
||||
<div
|
||||
className={"m-1 flex flex-col justify-start items-center w-5/6 self-center h-full"}>
|
||||
<div className={"flex w-full justify-start items-center mb-4"}>
|
||||
<SearchBar
|
||||
stateSetter={setSearchField} />
|
||||
</div>
|
||||
<FilesDataTable columns={filesTableColumns} searchField={searchField} />
|
||||
</div>
|
||||
</section>)
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel, getPaginationRowModel, getSortedRowModel, SortingState,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -17,169 +19,207 @@ import {
|
||||
} from "../ui/table"
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge'
|
||||
import { ArrowUpDown, Clock, Download, Trash } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ArrowUpDown, Clock, Download, Trash, TriangleAlert } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction, useMemo, useReducer, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// This type is used to define the shape of our data.
|
||||
// You can use a Zod schema here if you want.
|
||||
export type IFile = {
|
||||
uuid: string;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
extension: string;
|
||||
groupId: string | null;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
isRestricted: boolean;
|
||||
isDocumentation: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
}
|
||||
import { IFile } from '../../types/file';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { FilesApi } from 'apps/frontend/src/requests/files';
|
||||
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
|
||||
import { LoadingSpinner } from 'apps/frontend/src/components/loading-spinner';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion';
|
||||
import {
|
||||
ISearchBarResult,
|
||||
SearchBar
|
||||
} from 'apps/frontend/src/components/tables/search-bar';
|
||||
import {
|
||||
TablePagination
|
||||
} from 'apps/frontend/src/components/tables/pagination';
|
||||
|
||||
function ContextButtonForFile() {
|
||||
return (<div className={"scale-75"}>
|
||||
<Button variant={"destructive"}><Trash/></Button>
|
||||
</div>)
|
||||
return (
|
||||
<div className={"scale-75"}>
|
||||
<Button variant={"destructive"}>
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const filesTableColumns: ColumnDef<IFile>[] = [
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: ({ column }) => {
|
||||
return (<div className={"flex justify-center items-center"}>
|
||||
header: ({ column }) => (
|
||||
<div className={"flex justify-center items-center"}>
|
||||
Nom du fichier
|
||||
</div>)
|
||||
},
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "uploadedBy",
|
||||
header: ({ column }) => {
|
||||
return (<div className={"flex justify-center items-center"}>
|
||||
header: ({ column }) => (
|
||||
<div className={"flex justify-center items-center"}>
|
||||
Autheur(s)
|
||||
</div>)
|
||||
},
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "uploadedAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={"flex w-full"}
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Ajouté le
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={"flex w-full"}
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Ajouté le
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.getValue("uploadedAt"))
|
||||
const formatted = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()} à ${date.getHours()}:${date.getMinutes()}`
|
||||
|
||||
return (<div className={"flex justify-center items-center"}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={"gap-1 flex w-fit items-center"}>
|
||||
<Clock className={"w-4 h-4"} />
|
||||
<p className={"font-light"}>
|
||||
{formatted}
|
||||
</p>
|
||||
</Badge>
|
||||
</div>)
|
||||
const date = new Date(row.getValue("uploadedAt"));
|
||||
const formatted = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()} à ${date.getHours()}:${date.getMinutes()}`;
|
||||
return (
|
||||
<div className={"flex justify-center items-center"}>
|
||||
<Badge variant="outline" className={"gap-1 flex w-fit items-center"}>
|
||||
<Clock className={"w-4 h-4"} />
|
||||
<p className={"font-light"}>
|
||||
{formatted}
|
||||
</p>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "extension",
|
||||
header: ({ column }) => {
|
||||
return (<div className={"flex justify-center items-center"}>
|
||||
header: ({ column }) => (
|
||||
<div className={"flex justify-center items-center"}>
|
||||
Extension du fichier
|
||||
</div>)
|
||||
},
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const extension = row.getValue("extension") as string;
|
||||
|
||||
return (<div className={"flex justify-center items-center"}>
|
||||
<code className={"bg-gray-300 p-1 px-2 rounded-full"}>{extension}</code>
|
||||
</div>)
|
||||
return (
|
||||
<div className={"flex justify-center items-center"}>
|
||||
<code className={"bg-gray-300 p-1 px-2 rounded-full"}>{extension}</code>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: ({ column }) => {
|
||||
return (<div className={"flex justify-center items-center"}>
|
||||
header: ({ column }) => (
|
||||
<div className={"flex justify-center items-center"}>
|
||||
Actions
|
||||
</div>)
|
||||
},
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const file = row.original
|
||||
|
||||
return (<div className={"flex gap"}>
|
||||
<Button variant={"ghost"} asChild>
|
||||
<Link
|
||||
href={`http://localhost:3333/api/files/${file.uuid}`}
|
||||
>
|
||||
<Download />
|
||||
Télécharger
|
||||
</Link>
|
||||
</Button>
|
||||
<ContextButtonForFile/>
|
||||
</div>)
|
||||
const file = row.original;
|
||||
return (
|
||||
<div className={"flex gap"}>
|
||||
<Button variant={"ghost"} asChild>
|
||||
<Link href={`http://localhost:3333/api/files/${file.uuid}`}>
|
||||
<Download />
|
||||
Télécharger
|
||||
</Link>
|
||||
</Button>
|
||||
<ContextButtonForFile />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
searchField: ISearchBarResult
|
||||
}
|
||||
|
||||
export function FilesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
columns,
|
||||
searchField
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const rerender = useReducer(() => ({}), {})[1];
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [data, setData] = useState<TData[]>([]);
|
||||
const [rowsCount, setRowsCount] = useState(0);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['files', pagination, searchField],
|
||||
queryFn: async () => {
|
||||
const response = await FilesApi.get.files(pagination, searchField.value);
|
||||
setRowsCount(response.count);
|
||||
setData(response.data as TData[]);
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 500,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { isPending, isError, error, isFetching, isPlaceholderData } = queryResult;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
pageCount: Math.ceil(rowsCount / pagination.pageSize),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
manualPagination: true,
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
if (isError) {
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Erreur</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error.message}
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Stack trace</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<code className="text-sm font-mono border-l-2 border-l-destructive h-fit">{error.stack}</code>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border w-full">
|
||||
<Table>
|
||||
<div className="w-full">
|
||||
{isPending && isFetching && isPlaceholderData ? <LoadingSpinner/> : <Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
@ -195,7 +235,10 @@ export function FilesDataTable<TData, TValue>({
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Table>}
|
||||
<div className={"flex w-full justify-end items-center"}>
|
||||
<TablePagination rowsCount={rowsCount} pagination={pagination} setPagination={setPagination}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
46
apps/frontend/src/components/tables/pagination.tsx
Normal file
46
apps/frontend/src/components/tables/pagination.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { PaginationState, Table } from '@tanstack/react-table';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Button } from 'apps/frontend/src/components/ui/button';
|
||||
|
||||
|
||||
export interface TablePaginationProps {
|
||||
rowsCount: number;
|
||||
pagination: PaginationState;
|
||||
setPagination: Dispatch<SetStateAction<PaginationState>>
|
||||
}
|
||||
|
||||
export function TablePagination(props: TablePaginationProps) {
|
||||
const totalPages = Math.ceil(props.rowsCount / props.pagination.pageSize)
|
||||
const currentPage = props.pagination.pageIndex
|
||||
const isMonoPage = totalPages === 1
|
||||
const hasNextPage = (props.pagination.pageIndex >= totalPages)
|
||||
const hasPreviousPage = !(props.pagination.pageIndex === 0)
|
||||
|
||||
if (!props.rowsCount) return (<></>);
|
||||
|
||||
return (<div className="flex items-center justify-end gap-4 space-x-2 py-4">
|
||||
{isMonoPage ? (<h2>Il n'y as qu'une seule page.</h2>) : (<h2>Page <em>{currentPage}</em> sur <em>{totalPages}</em></h2>)}
|
||||
<div className={"flex gap-2 justify-center items-center"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
|
||||
}}
|
||||
disabled={!hasPreviousPage}
|
||||
>
|
||||
Page précédente
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
|
||||
}}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
Page suivante
|
||||
</Button>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
42
apps/frontend/src/components/tables/search-bar.tsx
Normal file
42
apps/frontend/src/components/tables/search-bar.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { LoadingSpinner } from 'apps/frontend/src/components/loading-spinner';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
|
||||
export interface SearchBarProps {
|
||||
stateSetter: Dispatch<SetStateAction<ISearchBarResult>>;
|
||||
}
|
||||
export interface ISearchBarResult {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function SearchBar(props: SearchBarProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => {
|
||||
props.stateSetter({value: inputValue});
|
||||
setIsLoading(false);
|
||||
}, 750);
|
||||
|
||||
// Nettoyage du timer
|
||||
return () => clearTimeout(timer);
|
||||
}, [inputValue]);
|
||||
return (<div className="flex gap-2">
|
||||
<div className="grid w-full max-w-md items-center gap-1.5">
|
||||
<Label htmlFor="searchFiled">Chercher un nom de fichier</Label>
|
||||
<Input
|
||||
type="search"
|
||||
id="searchFiled"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Votre recherche..." />
|
||||
</div>
|
||||
{isLoading && <Loader className={"w-6 h-6 animate-spin"} />}
|
||||
</div>)
|
||||
}
|
@ -12,8 +12,8 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { Label } from "./label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
|
608
apps/frontend/src/components/ui/multiple-selector.tsx
Normal file
608
apps/frontend/src/components/ui/multiple-selector.tsx
Normal file
@ -0,0 +1,608 @@
|
||||
'use client';
|
||||
|
||||
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
|
||||
import { Badge } from './badge';
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from './command';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disable?: boolean;
|
||||
/** fixed option that can't be removed. */
|
||||
fixed?: boolean;
|
||||
/** Group the options by providing key. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
interface GroupOption {
|
||||
[key: string]: Option[];
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
defaultOptions?: Option[];
|
||||
/** manually controlled options */
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
/** Loading component. */
|
||||
loadingIndicator?: React.ReactNode;
|
||||
/** Empty component. */
|
||||
emptyIndicator?: React.ReactNode;
|
||||
/** Debounce time for async search. Only work with `onSearch`. */
|
||||
delay?: number;
|
||||
/**
|
||||
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
||||
* For example, when user click on the input, it will trigger the search to get initial options.
|
||||
**/
|
||||
triggerSearchOnFocus?: boolean;
|
||||
/** async search */
|
||||
onSearch?: (value: string) => Promise<Option[]>;
|
||||
/**
|
||||
* sync search. This search will not showing loadingIndicator.
|
||||
* The rest props are the same as async search.
|
||||
* i.e.: creatable, groupBy, delay.
|
||||
**/
|
||||
onSearchSync?: (value: string) => Option[];
|
||||
onChange?: (options: Option[]) => void;
|
||||
/** Limit the maximum number of selected options. */
|
||||
maxSelected?: number;
|
||||
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
|
||||
onMaxSelected?: (maxLimit: number) => void;
|
||||
/** Hide the placeholder when there are options selected. */
|
||||
hidePlaceholderWhenSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Group the options base on provided key. */
|
||||
groupBy?: string;
|
||||
className?: string;
|
||||
badgeClassName?: string;
|
||||
/**
|
||||
* First item selected is a default behavior by cmdk. That is why the default is true.
|
||||
* This is a workaround solution by add a dummy item.
|
||||
*
|
||||
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
||||
*/
|
||||
selectFirstItem?: boolean;
|
||||
/** Allow user to create option when there is no option matched. */
|
||||
creatable?: boolean;
|
||||
/** Props of `Command` */
|
||||
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
|
||||
/** Props of `CommandInput` */
|
||||
inputProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
'value' | 'placeholder' | 'disabled'
|
||||
>;
|
||||
/** hide the clear all button. */
|
||||
hideClearAllButton?: boolean;
|
||||
}
|
||||
|
||||
export interface MultipleSelectorRef {
|
||||
selectedValue: Option[];
|
||||
input: HTMLInputElement;
|
||||
focus: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function transToGroupOption(options: Option[], groupBy?: string) {
|
||||
if (options.length === 0) {
|
||||
return {};
|
||||
}
|
||||
if (!groupBy) {
|
||||
return {
|
||||
'': options,
|
||||
};
|
||||
}
|
||||
|
||||
const groupOption: GroupOption = {};
|
||||
options.forEach((option) => {
|
||||
const key = (option[groupBy] as string) || '';
|
||||
if (!groupOption[key]) {
|
||||
groupOption[key] = [];
|
||||
}
|
||||
groupOption[key].push(option);
|
||||
});
|
||||
return groupOption;
|
||||
}
|
||||
|
||||
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
|
||||
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
|
||||
|
||||
for (const [key, value] of Object.entries(cloneOption)) {
|
||||
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
|
||||
}
|
||||
return cloneOption;
|
||||
}
|
||||
|
||||
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
||||
for (const [, value] of Object.entries(groupOption)) {
|
||||
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
||||
* So we create one and copy the `Empty` implementation from `cmdk`.
|
||||
*
|
||||
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
|
||||
**/
|
||||
const CommandEmpty = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const render = useCommandState((state) => state.filtered.count === 0);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn('py-6 text-center text-sm', className)}
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CommandEmpty.displayName = 'CommandEmpty';
|
||||
|
||||
const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
defaultOptions: arrayDefaultOptions = [],
|
||||
options: arrayOptions,
|
||||
delay,
|
||||
onSearch,
|
||||
onSearchSync,
|
||||
loadingIndicator,
|
||||
emptyIndicator,
|
||||
maxSelected = Number.MAX_SAFE_INTEGER,
|
||||
onMaxSelected,
|
||||
hidePlaceholderWhenSelected,
|
||||
disabled,
|
||||
groupBy,
|
||||
className,
|
||||
badgeClassName,
|
||||
selectFirstItem = true,
|
||||
creatable = false,
|
||||
triggerSearchOnFocus = false,
|
||||
commandProps,
|
||||
inputProps,
|
||||
hideClearAllButton = false,
|
||||
}: MultipleSelectorProps,
|
||||
ref: React.Ref<MultipleSelectorRef>,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [onScrollbar, setOnScrollbar] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
selectedValue: [...selected],
|
||||
input: inputRef.current as HTMLInputElement,
|
||||
focus: () => inputRef?.current?.focus(),
|
||||
reset: () => setSelected([])
|
||||
}),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
inputRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnselect = React.useCallback(
|
||||
(option: Option) => {
|
||||
const newOptions = selected.filter((s) => s.value !== option.value);
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
},
|
||||
[onChange, selected],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (input.value === '' && selected.length > 0) {
|
||||
const lastSelectOption = selected[selected.length - 1];
|
||||
// If last item is fixed, we should not remove it.
|
||||
if (!lastSelectOption.fixed) {
|
||||
handleUnselect(selected[selected.length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is not a default behavior of the <input /> field
|
||||
if (e.key === 'Escape') {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleUnselect, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
/** If `onSearch` is provided, do not trigger options updated. */
|
||||
if (!arrayOptions || onSearch) {
|
||||
return;
|
||||
}
|
||||
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
||||
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
||||
setOptions(newOption);
|
||||
}
|
||||
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
|
||||
|
||||
useEffect(() => {
|
||||
/** sync search */
|
||||
|
||||
const doSearchSync = () => {
|
||||
const res = onSearchSync?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearchSync || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
doSearchSync();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
doSearchSync();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
/** async search */
|
||||
|
||||
const doSearch = async () => {
|
||||
setIsLoading(true);
|
||||
const res = await onSearch?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearch || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
await doSearch();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
await doSearch();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
const CreatableItem = () => {
|
||||
if (!creatable) return undefined;
|
||||
if (
|
||||
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
||||
selected.find((s) => s.value === inputValue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Item = (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(value: string) => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, { value, label: value }];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
>
|
||||
{`Create "${inputValue}"`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
// For normal creatable
|
||||
if (!onSearch && inputValue.length > 0) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
// For async search creatable. avoid showing creatable item before loading at first.
|
||||
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const EmptyItem = React.useCallback(() => {
|
||||
if (!emptyIndicator) return undefined;
|
||||
|
||||
// For async search that showing emptyIndicator
|
||||
if (onSearch && !creatable && Object.keys(options).length === 0) {
|
||||
return (
|
||||
<CommandItem value="-" disabled>
|
||||
{emptyIndicator}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||
}, [creatable, emptyIndicator, onSearch, options]);
|
||||
|
||||
const selectables = React.useMemo<GroupOption>(
|
||||
() => removePickedOption(options, selected),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
return commandProps.filter;
|
||||
}
|
||||
|
||||
if (creatable) {
|
||||
return (value: string, search: string) => {
|
||||
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
|
||||
};
|
||||
}
|
||||
// Using default filter in `cmdk`. We don't have to provide it.
|
||||
return undefined;
|
||||
}, [creatable, commandProps?.filter]);
|
||||
|
||||
return (
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
{...commandProps}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e);
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
|
||||
shouldFilter={
|
||||
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
|
||||
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
||||
filter={commandFilter()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
||||
{
|
||||
'px-3 py-2': selected.length !== 0,
|
||||
'cursor-text': !disabled && selected.length !== 0,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-wrap gap-1">
|
||||
{selected.map((option) => {
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
|
||||
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
className={cn(
|
||||
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
(disabled || option.fixed) && 'hidden',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUnselect(option);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!onScrollbar) {
|
||||
setOpen(false);
|
||||
}
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setOpen(true);
|
||||
triggerSearchOnFocus && onSearch?.(debouncedSearchTerm);
|
||||
inputProps?.onFocus?.(event);
|
||||
}}
|
||||
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
|
||||
className={cn(
|
||||
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
|
||||
{
|
||||
'w-full': hidePlaceholderWhenSelected,
|
||||
'px-3 py-2': selected.length === 0,
|
||||
'ml-1': selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(selected.filter((s) => s.fixed));
|
||||
onChange?.(selected.filter((s) => s.fixed));
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-0 h-6 w-6 p-0',
|
||||
(hideClearAllButton ||
|
||||
disabled ||
|
||||
selected.length < 1 ||
|
||||
selected.filter((s) => s.fixed).length === selected.length) &&
|
||||
'hidden',
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
<CommandList
|
||||
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
|
||||
onMouseLeave={() => {
|
||||
setOnScrollbar(false);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setOnScrollbar(true);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>{loadingIndicator}</>
|
||||
) : (
|
||||
<>
|
||||
{EmptyItem()}
|
||||
{CreatableItem()}
|
||||
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
|
||||
{Object.entries(selectables).map(([key, dropdowns]) => (
|
||||
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
|
||||
<>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disable}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, option];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
option.disable && 'cursor-default text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultipleSelector.displayName = 'MultipleSelector';
|
||||
export default MultipleSelector;
|
67
apps/frontend/src/hooks/use-controllable-state.ts
Normal file
67
apps/frontend/src/hooks/use-controllable-state.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { useCallbackRef } from "@/hooks/use-callback-ref"
|
||||
|
||||
/**
|
||||
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
|
||||
*/
|
||||
|
||||
type UseControllableStateParams<T> = {
|
||||
prop?: T | undefined
|
||||
defaultProp?: T | undefined
|
||||
onChange?: (state: T) => void
|
||||
}
|
||||
|
||||
type SetStateFn<T> = (prevState?: T) => T
|
||||
|
||||
function useControllableState<T>({
|
||||
prop,
|
||||
defaultProp,
|
||||
onChange = () => {},
|
||||
}: UseControllableStateParams<T>) {
|
||||
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
|
||||
defaultProp,
|
||||
onChange,
|
||||
})
|
||||
const isControlled = prop !== undefined
|
||||
const value = isControlled ? prop : uncontrolledProp
|
||||
const handleChange = useCallbackRef(onChange)
|
||||
|
||||
const setValue: React.Dispatch<React.SetStateAction<T | undefined>> =
|
||||
React.useCallback(
|
||||
(nextValue) => {
|
||||
if (isControlled) {
|
||||
const setter = nextValue as SetStateFn<T>
|
||||
const value =
|
||||
typeof nextValue === "function" ? setter(prop) : nextValue
|
||||
if (value !== prop) handleChange(value as T)
|
||||
} else {
|
||||
setUncontrolledProp(nextValue)
|
||||
}
|
||||
},
|
||||
[isControlled, prop, setUncontrolledProp, handleChange]
|
||||
)
|
||||
|
||||
return [value, setValue] as const
|
||||
}
|
||||
|
||||
function useUncontrolledState<T>({
|
||||
defaultProp,
|
||||
onChange,
|
||||
}: Omit<UseControllableStateParams<T>, "prop">) {
|
||||
const uncontrolledState = React.useState<T | undefined>(defaultProp)
|
||||
const [value] = uncontrolledState
|
||||
const prevValueRef = React.useRef(value)
|
||||
const handleChange = useCallbackRef(onChange)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevValueRef.current !== value) {
|
||||
handleChange(value as T)
|
||||
prevValueRef.current = value
|
||||
}
|
||||
}, [value, prevValueRef, handleChange])
|
||||
|
||||
return uncontrolledState
|
||||
}
|
||||
|
||||
export { useControllableState }
|
@ -4,3 +4,42 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
opts: {
|
||||
decimals?: number
|
||||
sizeType?: "accurate" | "normal"
|
||||
} = {}
|
||||
) {
|
||||
const { decimals = 0, sizeType = "normal" } = opts
|
||||
|
||||
const sizes = ["Octets", "Ko", "Mo", "Go", "To"]
|
||||
const accurateSizes = ["Octets", "Kio", "Mio", "Gio", "Tio"]
|
||||
if (bytes === 0) return "0 Byte"
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / (1024 ** i)).toFixed(decimals)} ${
|
||||
sizeType === "accurate" ? accurateSizes[i] ?? "Bytest" : sizes[i] ?? "Bytes"
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stole this from the @radix-ui/primitive
|
||||
* @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
|
||||
*/
|
||||
export function composeEventHandlers<E>(
|
||||
originalEventHandler?: (event: E) => void,
|
||||
ourEventHandler?: (event: E) => void,
|
||||
{ checkForDefaultPrevented = true } = {}
|
||||
) {
|
||||
return function handleEvent(event: E) {
|
||||
originalEventHandler?.(event)
|
||||
|
||||
if (
|
||||
checkForDefaultPrevented === false ||
|
||||
!(event as unknown as Event).defaultPrevented
|
||||
) {
|
||||
return ourEventHandler?.(event)
|
||||
}
|
||||
}
|
||||
}
|
83
apps/frontend/src/requests/files.ts
Normal file
83
apps/frontend/src/requests/files.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { PaginationState } from "@tanstack/react-table";
|
||||
import { IFile, IFilesResponse } from "../types/file";
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = "http://localhost:3333/";
|
||||
const PAGE_LIMIT = 10;
|
||||
|
||||
function getFullRoute(route: string) {
|
||||
return API_URL + route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a list of files from the server based on pagination and an optional search field.
|
||||
*
|
||||
* @param {PaginationState} pagination - The current state of pagination, including page size and page index.
|
||||
* @param {string} [searchField] - An optional field used to filter the files by a search term.
|
||||
* @return {Promise<IFilesResponse>} A promise that resolves to an IFilesResponse object containing the list of files.
|
||||
*/
|
||||
async function getFiles(
|
||||
pagination: PaginationState,
|
||||
searchField?: string,
|
||||
): Promise<IFilesResponse> {
|
||||
const offset = pagination.pageSize * pagination.pageIndex;
|
||||
const res = await fetch(
|
||||
`${getFullRoute("api/files/find")}?limit=${PAGE_LIMIT}&offset=${offset}${searchField ? `&search=${searchField}` : "&search="}`,
|
||||
);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the server with specified metadata.
|
||||
*
|
||||
* @param {File} file - The file to be uploaded.
|
||||
* @param {string} fileName - The name to be assigned to the uploaded file.
|
||||
* @param {string} uploadedBy - The identifier for the user uploading the file.
|
||||
* @param {Array<string>} machineIds - An array of machine IDs associated with the file.
|
||||
* @param {string|null} groupId - The group ID for grouping files, if applicable.
|
||||
* @param {boolean} [isDocumentation=false] - Flag indicating if the file is documentation.
|
||||
* @param {boolean} [isRestricted=false] - Flag indicating if the file is restricted.
|
||||
* @return {Promise<Object>} A promise resolving to the server's response data.
|
||||
*/
|
||||
async function uploadFile(
|
||||
file: File,
|
||||
fileName: string,
|
||||
uploadedBy: string,
|
||||
machineIds: Array<string>,
|
||||
groupId: string|null = null,
|
||||
isDocumentation = false,
|
||||
isRestricted = false
|
||||
) {
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
file_name: fileName,
|
||||
uploaded_by: uploadedBy,
|
||||
//machine_id: machineIds.join(","), // Assuming machine IDs should be a comma-separated string
|
||||
machine_id: machineIds,
|
||||
is_documentation: isDocumentation.toString(),
|
||||
is_restricted: isRestricted.toString(),
|
||||
};
|
||||
|
||||
if (groupId) {
|
||||
// @ts-ignore
|
||||
headers["group_id"] = groupId;
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(getFullRoute("api/files/new"), file, { headers });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const FilesApi = {
|
||||
get: {
|
||||
files: getFiles,
|
||||
},
|
||||
post: {
|
||||
upload: uploadFile,
|
||||
}
|
||||
};
|
33
apps/frontend/src/requests/machines.ts
Normal file
33
apps/frontend/src/requests/machines.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { PaginationState } from "@tanstack/react-table";
|
||||
import { IFile, IFilesResponse } from "../types/file";
|
||||
import axios from 'axios';
|
||||
import { IMachine } from 'apps/frontend/src/types/machine';
|
||||
|
||||
const API_URL = "http://localhost:3333/";
|
||||
const PAGE_LIMIT = 10;
|
||||
|
||||
function getFullRoute(route: string) {
|
||||
return API_URL + route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a list of files from the server.
|
||||
*
|
||||
* @return {Promise<IMachine[]>} A promise that resolves to an IFilesResponse object containing the list of files.
|
||||
*/
|
||||
async function getMachines(): Promise<IMachine[]> {
|
||||
const response = await axios.get<IMachine[]>(getFullRoute("api/machines/find"), {
|
||||
params: {
|
||||
limit: -1,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const MachinesApi = {
|
||||
get: {
|
||||
all: getMachines
|
||||
}
|
||||
}
|
0
apps/frontend/src/temp.tsx
Normal file
0
apps/frontend/src/temp.tsx
Normal file
20
apps/frontend/src/types/file.ts
Normal file
20
apps/frontend/src/types/file.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type IFile = {
|
||||
uuid: string;
|
||||
fileName: string;
|
||||
checksum: string;
|
||||
extension: string;
|
||||
groupId: string | null;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
isRestricted: boolean;
|
||||
isDocumentation: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
};
|
||||
|
||||
export interface IFilesResponse {
|
||||
count: number;
|
||||
limit: number;
|
||||
currentOffset: number;
|
||||
data: Array<IFile>;
|
||||
}
|
5
apps/frontend/src/types/machine.ts
Normal file
5
apps/frontend/src/types/machine.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type IMachine = {
|
||||
id: string;
|
||||
machineName: string;
|
||||
machineType: string;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user