From 9a0c6c1f0b667c51dac2e984d4da21ff90b716f3 Mon Sep 17 00:00:00 2001 From: Ardeman Date: Mon, 10 Mar 2025 12:21:08 +0800 Subject: [PATCH] feat: implement file upload functionality and enhance admin dashboard layout --- app/apis/admin/get-users.ts | 2 - app/apis/admin/upload-file.ts | 37 +++++++ app/components/icons/chevron-double.tsx | 28 ------ app/components/ui/input-file.tsx | 12 ++- app/components/ui/pagination.tsx | 66 ------------- app/contexts/admin.tsx | 17 ++-- app/layouts/admin/dashboard.tsx | 3 +- app/layouts/admin/form-upload.tsx | 122 ++++++++++++++++++++++++ app/routes/actions.admin.upload.tsx | 64 +++++++++++++ 9 files changed, 246 insertions(+), 105 deletions(-) create mode 100644 app/apis/admin/upload-file.ts delete mode 100644 app/components/icons/chevron-double.tsx delete mode 100644 app/components/ui/pagination.tsx create mode 100644 app/layouts/admin/form-upload.tsx create mode 100644 app/routes/actions.admin.upload.tsx diff --git a/app/apis/admin/get-users.ts b/app/apis/admin/get-users.ts index 56d17e7..db411c4 100644 --- a/app/apis/admin/get-users.ts +++ b/app/apis/admin/get-users.ts @@ -26,9 +26,7 @@ const usersResponseSchema = z.object({ data: z.array(userResponseSchema), }) -export type TSubscribePlanRespon = z.infer export type TUserResponse = z.infer -export type TSubscribeResponse = z.infer export const getUsers = async (parameters: THttpServer) => { try { diff --git a/app/apis/admin/upload-file.ts b/app/apis/admin/upload-file.ts new file mode 100644 index 0000000..e94f376 --- /dev/null +++ b/app/apis/admin/upload-file.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +import type { TUploadSchema } from '~/layouts/admin/form-upload' +import { HttpServer, type THttpServer } from '~/libs/http-server' + +const uploadResponseSchema = z.object({ + data: z.object({ + message: z.string(), + data: z.object({ + file_path: z.string(), + file_url: z.string(), + }), + }), +}) + +type TParameter = { + payload: TUploadSchema & { + file: File + } +} & THttpServer + +export const uploadFileRequest = async (parameters: TParameter) => { + const { payload, ...restParameters } = parameters + const formdata = new FormData() + formdata.append('file', payload.file) + + try { + const { data } = await HttpServer(restParameters).post( + '/api/file', + formdata, + ) + return uploadResponseSchema.parse(data) + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error) + } +} diff --git a/app/components/icons/chevron-double.tsx b/app/components/icons/chevron-double.tsx deleted file mode 100644 index db9cadf..0000000 --- a/app/components/icons/chevron-double.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { JSX, SVGProps } from 'react' - -/** - * Note: `ChevronDoubleIcon` default mengarah ke kiri. - * Gunakan class `rotate-xx` untuk mengubah arah ikon. - */ -export const ChevronDoubleIcon = ( - properties: JSX.IntrinsicAttributes & SVGProps, -) => { - return ( - - - - ) -} diff --git a/app/components/ui/input-file.tsx b/app/components/ui/input-file.tsx index b3570cc..e3ff8de 100644 --- a/app/components/ui/input-file.tsx +++ b/app/components/ui/input-file.tsx @@ -1,6 +1,6 @@ import { Field, Label, Input as HeadlessInput } from '@headlessui/react' import { CloudArrowUpIcon } from '@heroicons/react/20/solid' -import { type ComponentProps, type ReactNode } from 'react' +import { useEffect, type ComponentProps, type ReactNode } from 'react' import { get, type FieldError, @@ -42,15 +42,23 @@ export const InputFile = >( labelClassName, ...restProperties } = properties - const { setIsUploadOpen } = useAdminContext() + const { setIsUploadOpen, uploadedFile } = useAdminContext() const { register, formState: { errors }, + setValue, } = useRemixFormContext() const error: FieldError = get(errors, name) + useEffect(() => { + if (uploadedFile) { + setValue('featured_image', uploadedFile) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadedFile]) + return ( void -} - -export const Pagination: React.FC = ({ - currentPage = 1, - totalPages, - onPageChange, -}) => { - const renderPageNumbers = () => { - const pages = [] - for (let index = 1; index <= totalPages; index++) { - pages.push( - , - ) - } - return pages - } - - return ( -
- - - - {renderPageNumbers()} - - - -
- ) -} diff --git a/app/contexts/admin.tsx b/app/contexts/admin.tsx index 7e093a1..6f87995 100644 --- a/app/contexts/admin.tsx +++ b/app/contexts/admin.tsx @@ -6,17 +6,19 @@ import { type Dispatch, type SetStateAction, } from 'react' +import { z } from 'zod' -type TUpload = - | 'featured_image' - | 'ads' - | 'content' - | 'profile_picture' - | undefined +export const uploadCategorySchema = z + .enum(['featured_image', 'ads', 'content', 'profile_picture']) + .optional() + +type TUpload = z.infer type AdminContextProperties = { isUploadOpen: TUpload setIsUploadOpen: Dispatch> + uploadedFile?: string + setUploadedFile: Dispatch> } const AdminContext = createContext( @@ -25,12 +27,15 @@ const AdminContext = createContext( export const AdminProvider = ({ children }: PropsWithChildren) => { const [isUploadOpen, setIsUploadOpen] = useState() + const [uploadedFile, setUploadedFile] = useState() return ( {children} diff --git a/app/layouts/admin/dashboard.tsx b/app/layouts/admin/dashboard.tsx index 74e7858..642918b 100644 --- a/app/layouts/admin/dashboard.tsx +++ b/app/layouts/admin/dashboard.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react' import { useAdminContext } from '~/contexts/admin' +import { FormUpload } from './form-upload' import { Navbar } from './navbar' import { Sidebar } from './sidebar' @@ -34,7 +35,7 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => { transition className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg" > - Upload di mari {isUploadOpen} + diff --git a/app/layouts/admin/form-upload.tsx b/app/layouts/admin/form-upload.tsx new file mode 100644 index 0000000..85b223a --- /dev/null +++ b/app/layouts/admin/form-upload.tsx @@ -0,0 +1,122 @@ +import { Button } from '@headlessui/react' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState, type ChangeEvent } from 'react' +import { useFetcher } from 'react-router' +import { RemixFormProvider, useRemixForm } from 'remix-hook-form' +import { z } from 'zod' + +import { uploadCategorySchema, useAdminContext } from '~/contexts/admin' + +export const uploadSchema = z.object({ + file: z.instanceof(File), + category: uploadCategorySchema, +}) + +export type TUploadSchema = z.infer + +export const FormUpload = () => { + const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext() + const fetcher = useFetcher() + const [disabled, setDisabled] = useState(false) + const [error, setError] = useState() + const maxFileSize = 1024 // 1MB + + const formMethods = useRemixForm({ + mode: 'onSubmit', + fetcher, + resolver: zodResolver(uploadSchema), + }) + + const { handleSubmit, register, setValue } = formMethods + + useEffect(() => { + if (!fetcher.data?.success) { + setError(fetcher.data?.message) + setDisabled(false) + return + } + + setUploadedFile(fetcher.data.uploadData.data.file_url) + setIsUploadOpen(undefined) + + setDisabled(true) + setError(undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher]) + + const handleChange = async function (event: ChangeEvent) { + event.preventDefault() + if (event.target.files && event.target.files[0]) { + const files: File[] = [...event.target.files] + + onChange(files, event) + } + } + + const onChange = async function ( + files: File[], + event: ChangeEvent, + ) { + const file = files[0] + const img = new Image() + + if (!file.type.startsWith('image/')) { + setError('Please upload an image file.') + return + } + + if (file.size > maxFileSize * 1024) { + setError(`File size is too big!`) + return + } + + img.addEventListener('load', () => { + handleFiles(event) + }) + + img.src = URL.createObjectURL(file) + } + + const handleFiles = (event: ChangeEvent) => { + const files = event.target.files + if (files && files.length > 0) { + const file = files[0] + setValue('file', file) + } + } + + return ( + + + {error && ( +
{error}
+ )} + + + +
+
+ ) +} diff --git a/app/routes/actions.admin.upload.tsx b/app/routes/actions.admin.upload.tsx new file mode 100644 index 0000000..6faa5d9 --- /dev/null +++ b/app/routes/actions.admin.upload.tsx @@ -0,0 +1,64 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { data } from 'react-router' +import { getValidatedFormData } from 'remix-hook-form' +import { XiorError } from 'xior' + +import { uploadFileRequest } from '~/apis/admin/upload-file' +import { uploadSchema, type TUploadSchema } from '~/layouts/admin/form-upload' +import { handleCookie } from '~/libs/cookies' + +import type { Route } from './+types/actions.register' + +export const action = async ({ request }: Route.ActionArgs) => { + const { staffToken } = await handleCookie(request) + try { + const { + errors, + data: payload, + receivedValues: defaultValues, + } = await getValidatedFormData( + request, + zodResolver(uploadSchema), + false, + ) + + if (errors) { + return data({ success: false, errors, defaultValues }, { status: 400 }) + } + + const { data: uploadData } = await uploadFileRequest({ + payload, + accessToken: staffToken, + }) + + return data( + { + success: true, + uploadData, + }, + { + status: 200, + statusText: 'OK', + }, + ) + } catch (error) { + if (error instanceof XiorError) { + return data( + { + success: false, + message: error?.response?.data?.error?.message || error.message, + }, + { + status: error?.response?.status || 500, + }, + ) + } + return data( + { + success: false, + message: 'Internal server error', + }, + { status: 500 }, + ) + } +}