diff --git a/components/common/UploadFile.tsx b/components/common/UploadFile.tsx new file mode 100644 index 00000000..a3a89de4 --- /dev/null +++ b/components/common/UploadFile.tsx @@ -0,0 +1,183 @@ +import { ChangeEvent, FormEvent, useRef, useState } from "react"; +import Button from "@components/common/Button"; +import { useAuthUserContext } from "../context/AuthUserContext"; +import { mutations } from "../../graphql/queries"; +import { fetchGraphqlUpload } from "../../utils/makegqlrequest"; + +const ACCEPTED_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png"]; +const ACCEPTED_EXTENSIONS = ".pdf,.jpg,.jpeg,.png"; + +type UploadStatus = "idle" | "uploading" | "success" | "error"; + +interface UploadFileProps { + onSuccess?: (result: any) => void; + onError?: (message: string) => void; +} + +const UploadFile = ({ onSuccess, onError }: UploadFileProps) => { + const { authenticatedUser } = useAuthUserContext(); + const [selectedFile, setSelectedFile] = useState(null); + const [validationError, setValidationError] = useState(""); + const [uploadStatus, setUploadStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const fileInputRef = useRef(null); + + const handleFileChange = (e: ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setValidationError(""); + setUploadStatus("idle"); + setErrorMessage(""); + + if (!file) { + setSelectedFile(null); + return; + } + + if (!ACCEPTED_MIME_TYPES.includes(file.type)) { + setValidationError("Only PDF, JPEG, and PNG files are accepted."); + setSelectedFile(null); + return; + } + + setSelectedFile(file); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!selectedFile) { + setValidationError("Please select a file before uploading."); + return; + } + + setUploadStatus("uploading"); + setErrorMessage(""); + + try { + const response = await fetchGraphqlUpload( + mutations.createFirebaseFile, + selectedFile, + "variables.file", + { + file: null, + uploadedUserId: parseInt(authenticatedUser?.id ?? "", 10), + }, + ); + + if (response.errors?.length) { + throw new Error(response.errors[0]?.message ?? "Upload failed."); + } + + setUploadStatus("success"); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + onSuccess?.(response.data); + } catch (err: any) { + const message = err.message ?? "An unexpected error occurred."; + setUploadStatus("error"); + setErrorMessage(message); + onError?.(message); + } + }; + + const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+

Upload File

+

+ Accepted formats: PDF, JPEG, PNG +

+ +
+ {/* Drop zone / file picker */} + + + {/* Validation error */} + {validationError && ( +

{validationError}

+ )} + + {/* Selected file info */} + {selectedFile && ( +
+
+ + {selectedFile.type === "application/pdf" + ? "PDF" + : selectedFile.type === "image/jpeg" + ? "JPEG" + : "PNG"} + + + {selectedFile.name} + +
+ + {formatFileSize(selectedFile.size)} + +
+ )} + + {/* Upload feedback */} + {uploadStatus === "success" && ( +
+ File uploaded successfully. +
+ )} + {uploadStatus === "error" && ( +
+ Upload failed: {errorMessage} +
+ )} + +
+ +
+
+
+ ); +}; + +export default UploadFile; diff --git a/graphql/queries.ts b/graphql/queries.ts index 06067ebe..3b5d30e3 100644 --- a/graphql/queries.ts +++ b/graphql/queries.ts @@ -37,6 +37,18 @@ export const mutations = { } } `, + createFirebaseFile: ` + mutation createFirebaseFile($file: Upload!, $uploadedUserId: Int!) { + createFirebaseFile(file: $file, uploadedUserId: $uploadedUserId) { + id + storagePath + originalFileName + sizeBytes + uploadedUserId + createdAt + } + } + `, }; // TODO: add functionaltiy to getRole in case accessToken expired and needs to be refreshed. diff --git a/pages/review/uploadFile.tsx b/pages/review/uploadFile.tsx new file mode 100644 index 00000000..be4ff128 --- /dev/null +++ b/pages/review/uploadFile.tsx @@ -0,0 +1,15 @@ +import { NextPage } from "next"; +import Layout from "@components/common/Layout"; +import UploadFile from "@components/common/UploadFile"; + +const UploadFilePage: NextPage = () => { + return ( + +
+ +
+
+ ); +}; + +export default UploadFilePage; diff --git a/utils/makegqlrequest.ts b/utils/makegqlrequest.ts index e801f1d8..ce37abc0 100644 --- a/utils/makegqlrequest.ts +++ b/utils/makegqlrequest.ts @@ -29,7 +29,7 @@ export async function fetchGraphql( const responseData = await response.json(); if (response.ok) { - return { data: responseData.data }; + return { data: responseData.data, errors: responseData.errors }; } else { throw new Error(JSON.stringify(responseData.errors)); } @@ -37,3 +37,54 @@ export async function fetchGraphql( throw new Error(`GraphQL request failed: ${error.message}`); } } + +/** + * Send a GraphQL mutation that includes a file upload. + * Follows the GraphQL multipart request spec: + * https://github.com/jaydenseric/graphql-multipart-request-spec + * + * @param query The mutation string. The file variable must be typed as `Upload!`. + * @param file The File (or Blob) to upload. + * @param fileVarPath Dot-path to the file variable inside `variables`, e.g. "variables.file". + * @param variables Non-file variables. Set the file variable to `null` here – this function + * maps the actual file onto it automatically. + */ +export async function fetchGraphqlUpload( + query: string, + file: File, + fileVarPath: string, + variables?: Record, +): Promise { + if (!BE_DEPLOYMENT_DOMAIN) { + throw new Error( + `DEPLOYMENT_DOMAIN not defined. Please check your env file.`, + ); + } + + const operations = JSON.stringify({ query, variables: variables ?? {} }); + // "0" is the arbitrary key we assign to the single uploaded file + const map = JSON.stringify({ "0": [fileVarPath] }); + + const formData = new FormData(); + formData.append("operations", operations); + formData.append("map", map); + formData.append("0", file, file.name); + + try { + // Do NOT set Content-Type manually – the browser fills in the correct + // multipart boundary automatically when using FormData. + const response = await fetch(BE_DEPLOYMENT_DOMAIN + "/graphql", { + method: "POST", + body: formData, + }); + const responseData = await response.json(); + + if (response.ok) { + return { data: responseData.data, errors: responseData.errors }; + } else { + throw new Error(JSON.stringify(responseData.errors)); + } + } catch (error: any) { + throw new Error(`GraphQL upload request failed: ${error.message}`); + } +}