diff --git a/apps/frontend/.env.sample b/apps/frontend/.env.sample index 55a4364..575b338 100644 --- a/apps/frontend/.env.sample +++ b/apps/frontend/.env.sample @@ -1 +1,3 @@ -VITE_API_BASE_URL=http://localhost:5000/ \ No newline at end of file +VITE_API_BASE_URL=http://localhost:5000/ +VITE_MAX_FILE_SIZE=3865470566 # ~3.6GB +VITE_MAX_FILE_UPLOAD_TIMEOUT=180000 diff --git a/apps/frontend/@/components/ui/input.tsx b/apps/frontend/@/components/ui/input.tsx index 19e7be9..a7383b0 100644 --- a/apps/frontend/@/components/ui/input.tsx +++ b/apps/frontend/@/components/ui/input.tsx @@ -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 {} @@ -18,6 +21,147 @@ const Input = React.forwardRef(({ className, type, ); }); +export interface FileInputProps extends React.InputHTMLAttributes { + fileName: string; + containerClassName?: string; + onFilesValueChange: (files: FileList) => void; +} + +const FileInput = React.forwardRef( + ( + { className, containerClassName, fileName, type, onFilesValueChange, ...props }, + ref: React.Ref, + ) => { + const filesNames = fileName.split(','); + + const innerRef = React.useRef(null); + + const containerRef = React.useRef(null); + + const dragImageRef = React.useRef(null); + + React.useImperativeHandle( + 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): 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): 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): 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 ( +
+ +
+ {/* todo: add scroll area here to display lots of files */} + +
+ {filesNames.map((name, index) => ( +

+ {name} +

+ ))} +
+
+
+ +
+
+
+
+ +

Drop files here

+
+
+
+ ); + }, +); + +FileInput.displayName = 'FileInput'; + Input.displayName = 'Input'; -export { Input }; +export { Input, FileInput }; diff --git a/apps/frontend/@/components/ui/loadingSpinner.tsx b/apps/frontend/@/components/ui/loadingSpinner.tsx new file mode 100644 index 0000000..7f54dba --- /dev/null +++ b/apps/frontend/@/components/ui/loadingSpinner.tsx @@ -0,0 +1,20 @@ +import { type FC } from 'react'; + +import { cn } from '../../lib/utils'; + +export const LoadingSpinner: FC<{ className?: string }> = ({ className }) => ( + + + +); diff --git a/apps/frontend/@/components/ui/scroll-area.tsx b/apps/frontend/@/components/ui/scroll-area.tsx index cf253cf..b0f053e 100644 --- a/apps/frontend/@/components/ui/scroll-area.tsx +++ b/apps/frontend/@/components/ui/scroll-area.tsx @@ -1,7 +1,7 @@ -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, @@ -9,38 +9,36 @@ const ScrollArea = React.forwardRef< >(({ className, children, ...props }, ref) => ( - - {children} - + {children} -)) -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName +)); + +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, orientation = "vertical", ...props }, ref) => ( +>(({ className, orientation = 'vertical', ...props }, ref) => ( -)) -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName +)); + +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; -export { ScrollArea, ScrollBar } +export { ScrollArea, ScrollBar }; diff --git a/apps/frontend/@/components/ui/table.tsx b/apps/frontend/@/components/ui/table.tsx index 2ff00b8..3cb53ea 100644 --- a/apps/frontend/@/components/ui/table.tsx +++ b/apps/frontend/@/components/ui/table.tsx @@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'; const Table = React.forwardRef>( ({ className, ...props }, ref) => ( -
+
{ + const queryClient = useQueryClient(); + const accessToken = useUserTokensStore.getState().accessToken; const [open, onOpenChange] = useState(false); @@ -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 => { @@ -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 ( diff --git a/apps/frontend/src/modules/common/services/httpService/httpService.ts b/apps/frontend/src/modules/common/services/httpService/httpService.ts index 25c4805..8fcffac 100644 --- a/apps/frontend/src/modules/common/services/httpService/httpService.ts +++ b/apps/frontend/src/modules/common/services/httpService/httpService.ts @@ -9,6 +9,8 @@ type RequestPayload = { * Currently only supports 'application/json' and 'text/plain'. */ body?: Record; + type?: 'json' | 'octet-stream'; + signal?: AbortSignal; }; type GetRequestPayload = Omit; @@ -75,16 +77,41 @@ export class HttpService { } public static async post(payload: RequestPayload): Promise> { - 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(); diff --git a/apps/frontend/src/modules/core/auth/requireAdmin.ts b/apps/frontend/src/modules/core/auth/requireAdmin.ts index dd98f6d..bf39871 100644 --- a/apps/frontend/src/modules/core/auth/requireAdmin.ts +++ b/apps/frontend/src/modules/core/auth/requireAdmin.ts @@ -5,8 +5,6 @@ import { type AppRouterContext } from '../router/routerContext'; export function requireAdmin(context: AppRouterContext): void { const { role, authenticated } = context; - console.log(context); - if (!authenticated) { throw redirect({ to: '/login', diff --git a/apps/frontend/src/modules/resource/api/user/mutations/createResourceMutation.ts b/apps/frontend/src/modules/resource/api/user/mutations/createResourceMutation.ts new file mode 100644 index 0000000..7d27a4f --- /dev/null +++ b/apps/frontend/src/modules/resource/api/user/mutations/createResourceMutation.ts @@ -0,0 +1,41 @@ +import { useMutation, type UseMutationResult, type UseMutationOptions } from '@tanstack/react-query'; + +import { type UploadResourcesPathParams } from '@common/contracts'; + +import { HttpService } from '../../../../common/services/httpService/httpService'; +import { type BaseApiError } from '../../../../common/services/httpService/types/baseApiError'; + +export interface CreateResourcesPayload extends UploadResourcesPathParams { + accessToken: string; + files: File[]; + signal?: AbortSignal; +} + +export const useCreateResourcesMutation = ( + options: UseMutationOptions, +): UseMutationResult => { + const createResources = async (payload: CreateResourcesPayload): Promise => { + const response = await HttpService.post({ + url: `/buckets/${payload.bucketName}/resources`, + // todo: add better types + // eslint-disable-next-line + body: payload.files as any, + headers: { + Authorization: `Bearer ${payload.accessToken}`, + }, + type: 'octet-stream', + signal: payload.signal, + }); + + if (!response.success) { + throw new Error(response.body.message); + } + + return; + }; + + return useMutation({ + mutationFn: createResources, + ...options, + }); +}; diff --git a/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx b/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx new file mode 100644 index 0000000..5e8ba92 --- /dev/null +++ b/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx @@ -0,0 +1,166 @@ +import { ArrowUpOnSquareIcon } from '@heroicons/react/20/solid'; +import { type FC, useEffect, useRef, useState } from 'react'; + +import { Button } from '../../../../../@/components/ui/button'; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '../../../../../@/components/ui/dialog'; +import { FileInput } from '../../../../../@/components/ui/input'; +import { LoadingSpinner } from '../../../../../@/components/ui/loadingSpinner'; +import { useFileUpload } from '../../composable/useFileUpload/useFileUpload'; + +interface CreateResourceModalProps { + bucketName: string; +} + +const MAX_FILE_SIZE = Number(import.meta.env['VITE_MAX_FILE_SIZE']); + +const acceptedImageAndVideoFormats = + 'audio/,video/quicktime,video/x-msvideo,video/x-ms-wmv,.jpg,.jpeg,.tiff,.webp,.raw,.png,.mp4,.mov,.avi,.mkv,.wmv,.flv,.webm,.mpeg,.mpg,.3gp,.ogg,.ts,.m4v,.m2ts,.vob,.rm,.rmvb,.divx,.asf,.swf,.f4v' as string; + +const allowedFormats = acceptedImageAndVideoFormats.replaceAll('.', '/').split(','); + +allowedFormats.push('audio/'); + +export const CreateResourceModal: FC = ({ bucketName }) => { + const [files, setFiles] = useState([]); + + const [fileName, setFileName] = useState(''); + + const [open, setOpen] = useState(false); + + const { abortController, isUploading, upload } = useFileUpload({ + files, + setFiles, + bucketName, + }); + + const fileInputRef = useRef(null); + + useEffect(() => { + let dataTransfer: DataTransfer | undefined; + + if (files.length > 0) { + dataTransfer = new DataTransfer(); + + const fileNameStringsArray = []; + + for (const file of files) { + dataTransfer.items.add(file); + + fileNameStringsArray.push(file.name); + } + + setFileName(fileNameStringsArray.join(',')); + + if (fileInputRef.current) { + fileInputRef.current.files = dataTransfer.files; + } + } + + if (fileInputRef.current && files.length === 0) { + fileInputRef.current.files = new DataTransfer().files; + + setFileName(''); + } + + return (): void => { + if (dataTransfer) { + dataTransfer.clearData(); + } + }; + }, [files]); + + const onUpload = async (): Promise => { + if (!files) { + setOpen(false); + + return; + } + + await upload(); + + setFileName(''); + + setOpen(false); + }; + + const isAllowedFormat = (type: string): boolean => { + return allowedFormats.find((item) => type.includes(item)) ? true : false; + }; + + return ( + { + setOpen(value); + + if (value === false) { + setFiles([]); + + setFileName(''); + + abortController.current = new AbortController(); + } + }} + > + + + + +
+ Add files to your bucket :) + { + const files = event.target?.files; + + if (!files) { + return; + } + + const validFiles = []; + + for (const file of files) { + const isOneOfAllowedFormats = isAllowedFormat(file.type); + + if (isOneOfAllowedFormats && file.size <= MAX_FILE_SIZE) { + validFiles.push(file); + } + } + + setFiles(validFiles.length > 0 ? validFiles : []); + }} + onFilesValueChange={(files) => { + const validFiles = []; + + for (const file of files) { + const isOneOfAllowedFormats = isAllowedFormat(file.type); + + if (isOneOfAllowedFormats && file.size <= MAX_FILE_SIZE) { + validFiles.push(file); + } + } + + setFiles(validFiles.length > 0 ? validFiles : []); + }} + accept={'audio/*,video/quicktime,video/x-msvideo,video/x-ms-wmv' + ',' + acceptedImageAndVideoFormats} + type="file" + multiple={true} + fileName={fileName} + > + +
+
+
+ ); +}; diff --git a/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx b/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx new file mode 100644 index 0000000..62b84cd --- /dev/null +++ b/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx @@ -0,0 +1,118 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { type MutableRefObject, useRef } from 'react'; + +import { useUserTokensStore } from '../../../core/stores/userTokens/userTokens'; +import { useCreateResourcesMutation } from '../../api/user/mutations/createResourceMutation'; + +interface UseFileUploadPayload { + files: File[]; + setFiles: (files: File[]) => void; + bucketName: string; +} + +interface UseFileUploadReturn { + upload: () => Promise; + abortController: MutableRefObject; + isUploading: boolean; +} + +const MAX_CHUNK_SIZE = 100_000_000; // ~100MB + +const FILE_UPLOAD_TIMEOUT = Number(import.meta.env['VITE_MAX_FILE_UPLOAD_TIMEOUT']); + +export const useFileUpload = ({ files, bucketName, setFiles }: UseFileUploadPayload): UseFileUploadReturn => { + const queryClient = useQueryClient(); + + const abortController = useRef(new AbortController()); + + const { mutateAsync, isPending: isUploading } = useCreateResourcesMutation({}); + + const accessToken = useUserTokensStore((selector) => selector.accessToken); + + const upload = async (): Promise => { + let runningTotalSize = 0; + + const filesCount = files.length; + + let filesToSend: File[] = []; + + for (let i = 0; i < filesCount; i += 1) { + const fileSize = files[i].size; + + runningTotalSize += fileSize; + + abortController.current.signal.addEventListener('abort', () => { + setFiles([]); + + runningTotalSize = 0; + }); + + if (fileSize > MAX_CHUNK_SIZE) { + const timeout = setTimeout(() => { + abortController.current.abort(); + }, FILE_UPLOAD_TIMEOUT); + + await mutateAsync({ + accessToken: accessToken as string, + bucketName, + files: [files[i] as File], + signal: abortController.current.signal, + }); + + clearTimeout(timeout); + + continue; + } + + filesToSend.push(files[i] as unknown as File); + + if (runningTotalSize >= MAX_CHUNK_SIZE) { + const timeout = setTimeout(() => { + abortController.current.abort(); + }, FILE_UPLOAD_TIMEOUT); + + await mutateAsync({ + accessToken: accessToken as string, + bucketName, + files: filesToSend, + signal: abortController.current.signal, + }); + + clearTimeout(timeout); + + filesToSend = []; + + runningTotalSize = 0; + } + + if (i === files.length - 1) { + const timeout = setTimeout(() => { + abortController.current.abort(); + }, FILE_UPLOAD_TIMEOUT); + + await mutateAsync({ + accessToken: accessToken as string, + bucketName, + files, + signal: abortController.current.signal, + }); + + clearTimeout(timeout); + + filesToSend = []; + } + } + + await queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === 'findBucketResources' && query.queryKey[1] === bucketName, + }); + + setFiles([]); + }; + + return { + upload, + abortController, + isUploading, + }; +}; diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 2d34f6e..e985477 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -8,33 +8,22 @@ // This file is auto-generated by TanStack Router -import { createFileRoute } from '@tanstack/react-router' - // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as IndexImport } from './routes/index' import { Route as LoginIndexImport } from './routes/login/index' import { Route as DashboardIndexImport } from './routes/dashboard/index' import { Route as AdminIndexImport } from './routes/admin/index' import { Route as AdminUserImport } from './routes/admin/user' import { Route as AdminBucketImport } from './routes/admin/bucket' -// Create Virtual Routes - -const AboutLazyImport = createFileRoute('/about')() -const IndexLazyImport = createFileRoute('/')() - // Create/Update Routes -const AboutLazyRoute = AboutLazyImport.update({ - path: '/about', - getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/about.lazy').then((d) => d.Route)) - -const IndexLazyRoute = IndexLazyImport.update({ +const IndexRoute = IndexImport.update({ path: '/', getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) +} as any) const LoginIndexRoute = LoginIndexImport.update({ path: '/login/', @@ -69,14 +58,7 @@ declare module '@tanstack/react-router' { id: '/' path: '/' fullPath: '/' - preLoaderRoute: typeof IndexLazyImport - parentRoute: typeof rootRoute - } - '/about': { - id: '/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof AboutLazyImport + preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } '/admin/bucket': { @@ -120,8 +102,7 @@ declare module '@tanstack/react-router' { // Create and export the route tree export const routeTree = rootRoute.addChildren({ - IndexLazyRoute, - AboutLazyRoute, + IndexRoute, AdminBucketRoute, AdminUserRoute, AdminIndexRoute, @@ -138,7 +119,6 @@ export const routeTree = rootRoute.addChildren({ "filePath": "__root.tsx", "children": [ "/", - "/about", "/admin/bucket", "/admin/user", "/admin/", @@ -147,10 +127,7 @@ export const routeTree = rootRoute.addChildren({ ] }, "/": { - "filePath": "index.lazy.tsx" - }, - "/about": { - "filePath": "about.lazy.tsx" + "filePath": "index.tsx" }, "/admin/bucket": { "filePath": "admin/bucket.tsx" diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 2c0b483..92cb7ba 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -50,12 +50,6 @@ function RootComponent(): JSX.Element {
{accessToken ? ( <> - - Home - {' '} {userRole === 'admin' && ( )} - - About - Dashboard diff --git a/apps/frontend/src/routes/about.lazy.tsx b/apps/frontend/src/routes/about.lazy.tsx deleted file mode 100644 index 15d9a01..0000000 --- a/apps/frontend/src/routes/about.lazy.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createLazyFileRoute } from '@tanstack/react-router'; - -export const Route = createLazyFileRoute('/about')({ - component: About, -}); - -function About(): JSX.Element { - return
Hello from About!
; -} diff --git a/apps/frontend/src/routes/dashboard/index.tsx b/apps/frontend/src/routes/dashboard/index.tsx index 0421dd7..6f4efc2 100644 --- a/apps/frontend/src/routes/dashboard/index.tsx +++ b/apps/frontend/src/routes/dashboard/index.tsx @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { z } from 'zod'; +import { LoadingSpinner } from '../../../@/components/ui/loadingSpinner'; import { findBucketsQueryOptions } from '../../modules/bucket/api/user/queries/findBuckets/findBucketsQueryOptions'; import { DataTable } from '../../modules/common/components/dataTable/dataTable'; import { requireAuth } from '../../modules/core/auth/requireAuth'; @@ -10,11 +11,12 @@ import { type AppRouterContext } from '../../modules/core/router/routerContext'; import { useUserStore } from '../../modules/core/stores/userStore/userStore'; import { useUserTokensStore } from '../../modules/core/stores/userTokens/userTokens'; import { findBucketResourcesQueryOptions } from '../../modules/resource/api/user/queries/findBucketResources/findBucketResourcesQueryOptions'; +import { CreateResourceModal } from '../../modules/resource/components/createResourceModal/createResourceModal'; import { imageTableColumns } from '../../modules/resource/components/imageTableColumns/imageTableColumns'; const searchSchema = z.object({ page: z.number().default(0), - bucketName: z.string().default(''), + bucketName: z.string().optional(), }); export const Route = createFileRoute('/dashboard/')({ @@ -48,12 +50,21 @@ function Dashboard(): JSX.Element { const { data: resourcesData, isFetched: isResourcesFetched } = useQuery({ ...findBucketResourcesQueryOptions({ accessToken: accessToken as string, - bucketName, + bucketName: bucketName ?? '', page, pageSize, }), }); + useEffect(() => { + if (!bucketName && isBucketsFetched && (bucketsData?.data?.length ?? 0) > 0) { + navigate({ + search: (prev) => ({ ...prev, bucketName: bucketsData?.data[0].name ?? '' }), + }); + } + // eslint-disable-next-line + }, [isBucketsFetched, bucketName, bucketsData?.data]); + const pageCount = useMemo(() => { return resourcesData?.metadata.totalPages || 1; }, [resourcesData?.metadata.totalPages]); @@ -76,23 +87,27 @@ function Dashboard(): JSX.Element { return (
- - {isBucketsFetched && !isResourcesFetched &&
Loading
} +
+ + +
+ {isBucketsFetched && !isResourcesFetched && bucketName && } {isBucketsFetched && isResourcesFetched && ( -

Welcome Home!

-
- ); -} diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx new file mode 100644 index 0000000..9018502 --- /dev/null +++ b/apps/frontend/src/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Navigate } from '@tanstack/react-router'; + +export const Route = createFileRoute('/')({ + component: () => , +}); diff --git a/apps/frontend/src/routes/login/index.tsx b/apps/frontend/src/routes/login/index.tsx index 2022db8..eea49b4 100644 --- a/apps/frontend/src/routes/login/index.tsx +++ b/apps/frontend/src/routes/login/index.tsx @@ -80,7 +80,10 @@ function Login(): JSX.Element { CookieService.setUserDataCookie(JSON.stringify(userData.data)); navigate({ - to: '/', + to: '/dashboard', + search: { + page: 1, + }, }); } catch (error) { if (error instanceof ApiError) { diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 85e847c..9527d97 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2021", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true,