diff --git a/functions/tsconfig.json b/functions/tsconfig.json index b34e6e37..4894bc7d 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -1,8 +1,6 @@ { "compileOnSave": true, - "include": [ - "src", - ], + "include": ["src"], "compilerOptions": { "module": "commonjs", "noImplicitReturns": true, @@ -14,12 +12,8 @@ "esModuleInterop": true, "noUnusedLocals": true, "paths": { - "@/*": [ - "./src/*" - ], - "tanam-shared/*": [ - "../shared/src/*" - ] + "@/*": ["./src/*"], + "tanam-shared/*": ["../shared/src/*"] } } } diff --git a/hosting/package-lock.json b/hosting/package-lock.json index c794718c..1f042590 100644 --- a/hosting/package-lock.json +++ b/hosting/package-lock.json @@ -28,6 +28,7 @@ "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-dom": "^18.3.1", + "react-image-crop": "^11.0.6", "tanam-shared": "file:../shared", "zod": "^3.23.8" }, @@ -5001,9 +5002,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { "braces": "^3.0.3", @@ -6126,6 +6127,14 @@ "react": "^18.3.1" } }, + "node_modules/react-image-crop": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.6.tgz", + "integrity": "sha512-T+/RPBhwFxdf8PjD/uoWk+tBkS0Xf2XW0lY5mnsmClvnAujO81EEjDwj0M2pcHX3seXVgKOr/yIiL+Sx4evMNw==", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/hosting/package.json b/hosting/package.json index c48140b6..a768198a 100644 --- a/hosting/package.json +++ b/hosting/package.json @@ -32,6 +32,7 @@ "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-dom": "^18.3.1", + "react-image-crop": "^11.0.6", "tanam-shared": "file:../shared", "zod": "^3.23.8" }, diff --git a/hosting/public/images/no-image.png b/hosting/public/images/no-image.png new file mode 100644 index 00000000..a180d5c5 Binary files /dev/null and b/hosting/public/images/no-image.png differ diff --git a/hosting/src/app/(protected)/settings/page.tsx b/hosting/src/app/(protected)/settings/page.tsx index 20d4e1f1..b65074ad 100644 --- a/hosting/src/app/(protected)/settings/page.tsx +++ b/hosting/src/app/(protected)/settings/page.tsx @@ -1,132 +1,293 @@ "use client"; +import Notification from "@/components/common/Notification"; import PageHeader from "@/components/common/PageHeader"; +import {CropImage} from "@/components/CropImage"; import DarkModeSwitcher from "@/components/DarkModeSwitcher"; +import {Dropzone} from "@/components/Form/Dropzone"; +import {Modal} from "@/components/Modal"; import {useAuthentication} from "@/hooks/useAuthentication"; +import {useFirebaseStorage} from "@/hooks/useFirebaseStorage"; import {useTanamUser} from "@/hooks/useTanamUser"; +import {UserNotification} from "@/models/UserNotification"; import Image from "next/image"; +import {useEffect, useState} from "react"; +import {AcceptFileType} from "tanam-shared/definitions/AcceptFileType"; + +const defaultImage = "/images/no-image.png"; export default function Settings() { const {authUser} = useAuthentication(); - const {tanamUser, saveUserInfo} = useTanamUser(authUser?.uid); + const {tanamUser, error: userError, saveUserInfo} = useTanamUser(authUser?.uid); + const {isLoading: uploadLoading, error: storageError, upload, getFile} = useFirebaseStorage(); + const [notification, setNotification] = useState(null); + + const [showDropzone, setShowDropzone] = useState(false); + const [showCropImage, setShowCropImage] = useState(false); + const [pathUpload, setPathUpload] = useState(); + const [fileUploadContentType, setFileUploadContentType] = useState(); + const [beforeCropImage, setBeforeCropImage] = useState(); + const [afterCropImage, setAfterCropImage] = useState(); + const [profilePicture, setProfilePicture] = useState(defaultImage); + + useEffect(() => { + if (tanamUser) { + init(); + } + }, [tanamUser, pathUpload]); + + useEffect(() => { + setNotification(userError || storageError); + }, [userError, storageError]); + + /** + * Initializes component by setting the required state. + * Loads the profile picture if upload path is available. + * @return {Promise} + */ + async function init(): Promise { + if (!pathUpload) { + setPathUpload(`tanam-users/${tanamUser?.id}`); + return; + } + + await resetChanges(); + } + + /** + * Resets upload and crop image-related states. + * @return {Promise} + */ + async function resetChanges(): Promise { + setFileUploadContentType(undefined); + resetCropImage(); + + await fetchProfilePicture(); + } + + /** + * Resets the crop image states. + */ + function resetCropImage() { + setBeforeCropImage(undefined); + setAfterCropImage(undefined); + setShowCropImage(false); + } + + /** + * Fetches the user's profile picture from Firebase Storage. + * Uses a default image if none is found. + * @return {Promise} + */ + async function fetchProfilePicture(): Promise { + const profilePictureUrl = await getFile(`${pathUpload}/profile.png`); - async function onPersonalInfoSubmit(event: React.FormEvent) { + setProfilePicture(profilePictureUrl ?? defaultImage); + setBeforeCropImage(profilePicture); + } + + /** + * Handles the submission of the personal information form. + * Uploads a new profile picture if provided and saves the user's name. + * @param {React.FormEvent} event - Form submission event. + * @return {Promise} + */ + async function onPersonalInfoSubmit(event: React.FormEvent): Promise { event.preventDefault(); - const form = event.currentTarget; - const formData = { - fullName: form.fullName.value, - }; - await saveUserInfo(formData.fullName); + + setNotification(null); + + try { + if (uploadLoading) return; + + const form = event.currentTarget; + const formData = { + fullName: form.fullName.value, + }; + + if (fileUploadContentType && profilePicture) { + const fileName = "new-profile-image"; + + await upload(`${pathUpload}/${fileName}`, profilePicture, fileUploadContentType); + } + + await saveUserInfo(formData.fullName); + + setNotification(new UserNotification("success", "Update Profile", "Success to update profile")); + } catch (error) { + setNotification(new UserNotification("error", "Update Profile", "Failed to update profile")); + } } + /** + * Modal actions for saving or canceling profile picture changes. + * @constant + * @type {JSX.Element} + */ + const modalActionCropImage = ( +
+ {/* Start button to close the crop image modal */} + + {/* End button to close the crop image modal */} + + {/* Start button to save changes to the profile picture after cropping */} + + {/* End button to save changes to the profile picture after cropping */} +
+ ); + return ( - <> -
- - -
-
-
-
-
-

System

-
-
-
- -
- -
-
+
+ {notification && ( + + )} + + + +
+ {/* Start System Settings Section */} +
+
+
+

System

+
+
+
+ +
+
+
+
+ {/* End System Settings Section */} -
-
-
-

Personal Information

-
-
-
-
-
-
- User -
-
- Edit your photo - - - - -
-
- -
- -
- -

- Click to upload or drag and drop -

-

SVG, PNG, JPG or GIF

-

(max, 800 X 800px)

-
-
+ {/* Start Personal Information Section */} +
+
+
+

Personal Information

+
+
+
+
+
+ User + +
+ Edit your photo + + + +
-
-
- -
- - -
+ {showDropzone && ( + { + if (!valueString) return; + + setBeforeCropImage(valueString); + setFileUploadContentType(valueBlob?.type); + setShowCropImage(true); + }} + /> + )} +
+
+ + +
+
+ +
+ +
+
-
- - -
+
+ +
-
- + +
+ {/* End Personal Information Section */}
- + + {/* Start modal crop image */} + + + + {/* End modal crop image */} +
); } diff --git a/hosting/src/components/CropImage.tsx b/hosting/src/components/CropImage.tsx new file mode 100644 index 00000000..87279462 --- /dev/null +++ b/hosting/src/components/CropImage.tsx @@ -0,0 +1,132 @@ +import Image from "next/image"; +import React, {useEffect, useRef, useState} from "react"; +import ReactCrop, {centerCrop, Crop, makeAspectCrop, PixelCrop} from "react-image-crop"; +import "react-image-crop/dist/ReactCrop.css"; + +export interface CropImageProps { + src?: string; + onCropComplete?: (croppedImageUrl: string) => void; + contentType?: string; +} + +/** + * CropImage component allows users to crop an image and get the resulting cropped image data. + * @param {CropImageProps} props - Props for the CropImage component. + * @return {JSX.Element | null} - The rendered CropImage component. + */ +export function CropImage(props: CropImageProps): JSX.Element | null { + if (!props.src) return null; + + const {src, onCropComplete, contentType = "image/jpeg"} = props; + + const [crop, setCrop] = useState(); + const [completedCrop, setCompletedCrop] = useState(null); + const [croppedImageUrl, setCroppedImageUrl] = useState(null); + const imageRef = useRef(null); + const previewCanvasRef = useRef(null); + + // Generate cropped image once the crop is complete and the crop area is valid + useEffect(() => { + if (completedCrop && imageRef.current && previewCanvasRef.current) { + generateCroppedImage(imageRef.current, previewCanvasRef.current, completedCrop, contentType); + } + }, [completedCrop, contentType]); + + /** + * Callback function triggered when the image is loaded. + * Calculates and sets an initial crop based on the image dimensions. + * @param {React.SyntheticEvent} e - The synthetic event containing the image data. + */ + function onImageLoad(e: React.SyntheticEvent): void { + const {naturalWidth: width, naturalHeight: height} = e.currentTarget; + + // Create a crop with a 16:9 aspect ratio, centered on the image + const crop = centerCrop( + makeAspectCrop( + { + // Initial crop dimensions and aspect ratio + unit: "%", + width: 90, + }, + 16 / 9, + width, + height, + ), + width, + height, + ); + + setCrop(crop); + } + + /** + * Callback function triggered when the crop operation is completed. + * Processes the cropped image area and generates a URL for the cropped image. + * @param {PixelCrop} pixelCrop - The pixel values of the crop area. + */ + function onCropCompleteInternal(pixelCrop: PixelCrop) { + setCompletedCrop(pixelCrop); + } + + /** + * Draw the cropped image on a canvas element and generate a data URL. + * @param {HTMLImageElement} image - The image element to be cropped. + * @param {HTMLCanvasElement} canvas - The canvas element to draw the cropped image on. + * @param {PixelCrop} crop - The crop area data. + * @param {string} contentType - The content type for the output image. + */ + function generateCroppedImage( + image: HTMLImageElement, + canvas: HTMLCanvasElement, + crop: PixelCrop, + contentType: string, + ) { + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + canvas.width = crop.width; + canvas.height = crop.height; + const ctx = canvas.getContext("2d"); + + if (ctx) { + ctx.drawImage( + image, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, + 0, + 0, + crop.width, + crop.height, + ); + + // Convert the canvas content to a data URL with the specified content type + const croppedImageUrl = canvas.toDataURL(contentType); + setCroppedImageUrl(croppedImageUrl); + + if (onCropComplete) { + onCropComplete(croppedImageUrl); + } + } + } + + return ( +
+ setCrop(newCrop)} onComplete={onCropCompleteInternal}> + Source image crop + + + {croppedImageUrl && ( +
+

Preview:

+ Crop preview +
+ )} +
+ ); +} diff --git a/hosting/src/components/Form/Dropzone.tsx b/hosting/src/components/Form/Dropzone.tsx new file mode 100644 index 00000000..6c2dd330 --- /dev/null +++ b/hosting/src/components/Form/Dropzone.tsx @@ -0,0 +1,128 @@ +"use client"; +import "@/components/Form/styles/dropzone.scss"; +import {getAcceptDescription, isFileAccepted} from "@/utils/fileUpload"; +import React, {useEffect, useRef, useState} from "react"; +import {AcceptFileType} from "tanam-shared/definitions/AcceptFileType"; + +export interface DropzoneProps { + value?: string; + disabled?: boolean; + accept?: AcceptFileType; + onChange?: (fileString: string | null, fileBlob: File | null) => void; +} + +/** + * Dropzone component for handling file uploads and sending data to parent without preview. + * @param {DropzoneProps} props - The properties for the dropzone component. + * @return {JSX.Element} The rendered dropzone component. + */ +export function Dropzone({value, disabled, accept = AcceptFileType.AllFiles, onChange}: DropzoneProps): JSX.Element { + const [dragActive, setDragActive] = useState(false); + const inputRef = useRef(null); + + /** + * useEffect hook that resets the file input value when the component unmounts. + */ + useEffect(() => { + return () => { + if (inputRef.current) { + inputRef.current.value = ""; + } + }; + }, [value]); + + /** + * Handles drag events (dragenter, dragover, dragleave) to manage the drag state. + * @param {React.DragEvent} e - The drag event triggered when a file is dragged over the dropzone. + */ + function handleDrag(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (disabled) return; + + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } + + if (e.type === "dragleave") { + setDragActive(false); + } + } + + /** + * Handles the drop event when a file is dropped into the dropzone. + * @param {React.DragEvent} e - The drop event triggered when a file is dropped. + */ + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (disabled) return; + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + const file = e.dataTransfer.files[0]; + + if (!isFileAccepted(file, accept)) { + console.error(`File type not accepted: ${file.name}`); + + return; + } + + handleFile(file, onChange); + } + } + + function handleChange( + e: React.ChangeEvent, + callback?: (fileString: string | null, fileBlob: File | null) => void, + ): void { + if (e.target.files && e.target.files[0]) { + handleFile(e.target.files[0], callback); + } + } + + function handleFile(file: File, callback?: (fileString: string | null, fileBlob: File | null) => void): void { + const reader = new FileReader(); + + reader.onloadend = () => { + if (callback) { + callback(reader.result as string, file); + } + }; + + reader.readAsDataURL(file); + } + + return ( +
+
+ handleChange(e, onChange)} + className="absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none" + /> +
+ +

+ Click to upload or drag and drop +

+

Allowed types: {getAcceptDescription(accept)}

+
+
+
+ ); +} diff --git a/hosting/src/components/Form/index.tsx b/hosting/src/components/Form/index.tsx index 897dd50d..60d81c9b 100644 --- a/hosting/src/components/Form/index.tsx +++ b/hosting/src/components/Form/index.tsx @@ -1,6 +1,7 @@ export {Checkbox} from "./Checkbox"; export {DatePicker} from "./DatePicker"; export {Dropdown} from "./Dropdown"; +export {Dropzone} from "./Dropzone"; export {FileUpload} from "./FileUpload"; export {FormGroup} from "./FormGroup"; export {Input} from "./Input"; diff --git a/hosting/src/components/Form/styles/dropzone.scss b/hosting/src/components/Form/styles/dropzone.scss new file mode 100644 index 00000000..24eefc41 --- /dev/null +++ b/hosting/src/components/Form/styles/dropzone.scss @@ -0,0 +1,11 @@ +.c-dropzone { + & .dropzone { + &__inner-wrapper { + &.drag { + &--active { + border-color: #4caf50 !important; + } + } + } + } +} diff --git a/hosting/src/components/Header.tsx b/hosting/src/components/Header.tsx index 51ec41fd..bc431d54 100644 --- a/hosting/src/components/Header.tsx +++ b/hosting/src/components/Header.tsx @@ -21,7 +21,7 @@ interface HeaderProps { */ export default function Header({sidebarOpen, setSidebarOpen}: HeaderProps) { return ( -
+
{/* Start Toggle Publish Document */} diff --git a/hosting/src/components/Modal.tsx b/hosting/src/components/Modal.tsx new file mode 100644 index 00000000..e296bca9 --- /dev/null +++ b/hosting/src/components/Modal.tsx @@ -0,0 +1,82 @@ +import React from "react"; + +interface ModalProps { + isOpen: boolean; + disableOverlayClose?: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + actions?: React.ReactNode; +} + +/** + * Reusable Modal component for displaying content in a modal dialog. + * @param {ModalProps} props - The properties for the Modal component. + * @return {JSX.Element | null} The rendered Modal component or null if not open. + */ +export function Modal({ + isOpen, + disableOverlayClose, + title, + children, + actions, + onClose, +}: ModalProps): JSX.Element | null { + // If the modal is not open, don't render anything + if (!isOpen) return null; + + return ( +
+ {/* Start background overlay */} +
{ + if (disableOverlayClose) return; + + onClose(); + }} + >
+ {/* End background overlay */} + +
+ {/* Start modal content */} +
+ {/* Start modal header */} +
+

{title}

+ +
+ {/* End modal header */} + + {/* Start modal body */} +
{children}
+ {/* End modal body */} + + {/* Modal footer */} +
+ {actions ? ( + actions + ) : ( + + )} +
+
+ {/* End modal content */} +
+
+ ); +} diff --git a/hosting/src/components/common/Notification.tsx b/hosting/src/components/common/Notification.tsx index 9ac939a6..ec809fb8 100644 --- a/hosting/src/components/common/Notification.tsx +++ b/hosting/src/components/common/Notification.tsx @@ -1,5 +1,3 @@ -import React from "react"; - interface NotificationProps { type: "warning" | "success" | "error"; title: string; @@ -27,7 +25,7 @@ function Notification({type, title, message}: NotificationProps) { return (
{IconComponent && }
diff --git a/hosting/src/hooks/useFirebaseStorage.tsx b/hosting/src/hooks/useFirebaseStorage.tsx new file mode 100644 index 00000000..d2201cad --- /dev/null +++ b/hosting/src/hooks/useFirebaseStorage.tsx @@ -0,0 +1,85 @@ +import {UserNotification} from "@/models/UserNotification"; +import {storage} from "@/plugins/firebase"; +import {base64ToBlob} from "@/utils/fileUpload"; +import {getDownloadURL, ref, uploadBytes} from "firebase/storage"; +import {useState} from "react"; + +interface FirebaseStorageHook { + isLoading: boolean; + error: UserNotification | null; + upload: (folderPath: string, base64: string, contentType: string) => Promise; + getFile: (filePath: string) => Promise; +} + +/** + * Custom hook for interacting with Firebase Storage. + * Provides functionality for uploading base64 encoded data and retrieving file URLs. + * @return {FirebaseStorageHook} - Returns an object with states and functions for managing Firebase Storage operations. + */ +export function useFirebaseStorage(): FirebaseStorageHook { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + /** + * Uploads a base64 encoded file to Firebase Storage. + * Converts the base64 string to a Blob object and uploads it to the specified folder path. + * After uploading, retrieves and returns the download URL of the uploaded file. + * @param {string} folderPath - The path in Firebase Storage where the file will be saved (e.g., 'images/user_profiles'). + * @param {string} base64 - The base64 encoded file string to be uploaded. + * @param {string} contentType - The MIME type of the file (e.g., 'image/jpeg', 'application/pdf'). + * @return {Promise} - A promise that resolves to the download URL of the uploaded file or null if the upload fails. + */ + async function upload(folderPath: string, base64: string, contentType: string): Promise { + setIsLoading(true); + setError(null); + + try { + const blob = base64ToBlob(base64, contentType); + const storageRef = ref(storage, folderPath); + + await uploadBytes(storageRef, blob); + + const downloadURL = await getFile(folderPath); + return downloadURL; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + setError(new UserNotification("error", "Problem uploading to storage", `Upload failed: ${errorMessage}`)); + + return null; + } finally { + setIsLoading(false); + } + } + + /** + * Retrieves the download URL for a file stored in Firebase Storage. + * @param {string} filePath - The path in Firebase Storage of the file (e.g., 'images/user_profiles/profile.jpg'). + * @return {Promise} - A promise that resolves to the download URL of the file or null if retrieval fails. + */ + async function getFile(filePath: string): Promise { + setIsLoading(true); + setError(null); + + try { + const storageRef = ref(storage, filePath); + + const downloadURL = await getDownloadURL(storageRef); + return downloadURL; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + setError( + new UserNotification( + "error", + "Problem getting file from storage", + `Failed to get download URL: ${errorMessage}`, + ), + ); + + return null; + } finally { + setIsLoading(false); + } + } + + return {isLoading, error, upload, getFile}; +} diff --git a/hosting/src/utils/fileUpload.ts b/hosting/src/utils/fileUpload.ts new file mode 100644 index 00000000..13d3e90d --- /dev/null +++ b/hosting/src/utils/fileUpload.ts @@ -0,0 +1,98 @@ +import {AcceptFileType} from "tanam-shared/definitions/AcceptFileType"; + +/** + * Converts a base64 string to a Blob object. + * @param {string} base64 - Base64 string of the file. + * @param {string} contentType - MIME type of the file (e.g., 'image/jpeg', 'application/pdf'). + * @return {Blob} - The Blob object created from the base64 string. + */ +export function base64ToBlob(base64: string, contentType: string): Blob { + const byteCharacters = atob(base64.split(",")[1]); + const byteNumbers = Array.from(byteCharacters, (char) => char.charCodeAt(0)); + const byteArray = new Uint8Array(byteNumbers); + + return new Blob([byteArray], {type: contentType}); +} + +/** + * Gets the file extension based on the MIME type. + * @param {string} contentType - MIME type of the file (e.g., 'image/jpeg', 'application/pdf'). + * @return {string} - File extension based on MIME type. + */ +export function getFileExtension(contentType: string): string { + const mimeTypeToExtension: {[key: string]: string} = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/bmp": ".bmp", + "image/webp": ".webp", + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "text/plain": ".txt", + "text/html": ".html", + "text/css": ".css", + "application/javascript": ".js", + "application/json": ".json", + "application/zip": ".zip", + "application/x-rar-compressed": ".rar", + "application/x-7z-compressed": ".7z", + // Add more mappings as needed + }; + + return mimeTypeToExtension[contentType] || ""; +} + +/** + * Utility function to get a user-friendly description of the accepted file types. + * @param {AcceptFileType} accept - The accept enum value. + * @return {string} A descriptive string for the accepted file types. + */ +export function getAcceptDescription(accept: AcceptFileType): string { + switch (accept) { + case AcceptFileType.AllImages: + return "Any image type"; + case AcceptFileType.Images: + return "Images (JPG, PNG, SVG)"; + case AcceptFileType.Pdf: + return "PDF files"; + case AcceptFileType.Word: + return "Word documents"; + case AcceptFileType.Excel: + return "Excel spreadsheets"; + case AcceptFileType.PowerPoint: + return "PowerPoint presentations"; + case AcceptFileType.Text: + return "Text files"; + case AcceptFileType.Audio: + return "Audio files"; + case AcceptFileType.Video: + return "Video files"; + case AcceptFileType.Zip: + return "Compressed files (ZIP, RAR, 7z)"; + case AcceptFileType.Csv: + return "CSV files"; + case AcceptFileType.AllFiles: + default: + return "Any file type"; + } +} + +/** + * Checks if the file is accepted based on the MIME type. + * @param {File} file - The file object to be checked. + * @param {AcceptFileType} accept - The accepted file type. + * @return {boolean} - True if the file is accepted, otherwise false. + */ +export function isFileAccepted(file: File, accept: AcceptFileType): boolean { + const fileExtension = getFileExtension(file.type); + const acceptedMimeTypes = accept.split(",").map((type) => type.trim()); + + // Check if the file's MIME type matches any of the accepted MIME types + return acceptedMimeTypes.some((type) => type === file.type || fileExtension === getFileExtension(type)); +} diff --git a/hosting/tsconfig.json b/hosting/tsconfig.json index 47d529b0..5b1845c7 100644 --- a/hosting/tsconfig.json +++ b/hosting/tsconfig.json @@ -1,11 +1,6 @@ { "compileOnSave": true, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" - ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "compilerOptions": { "module": "esnext", "noImplicitReturns": true, @@ -15,11 +10,7 @@ "target": "es2017", "skipLibCheck": true, "esModuleInterop": true, - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "noEmit": true, "moduleResolution": "bundler", @@ -34,15 +25,9 @@ } ], "paths": { - "@/*": [ - "./src/*" - ], - "tanam-shared/*": [ - "../shared/src/*" - ] + "@/*": ["./src/*"], + "tanam-shared/*": ["../shared/src/*"] } }, - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/shared/src/definitions/AcceptFileType.ts b/shared/src/definitions/AcceptFileType.ts new file mode 100644 index 00000000..7c1e2b9c --- /dev/null +++ b/shared/src/definitions/AcceptFileType.ts @@ -0,0 +1,15 @@ +// Enum to define acceptable file types for the Dropzone component +export enum AcceptFileType { + AllImages = "image/*", + Images = "image/jpg, image/jpeg, image/png, image/svg", + Pdf = "application/pdf", + Word = "application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Excel = "application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + PowerPoint = "application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation", + Text = "text/plain", + Audio = "audio/*", + Video = "video/*", + Zip = "application/zip, application/x-rar-compressed, application/x-7z-compressed", + Csv = "text/csv", + AllFiles = "*/*", +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 0abae52d..383687ac 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from "./definitions/AcceptFileType"; export * from "./definitions/LanguageCode"; export * from "./models/LocalizedString"; diff --git a/storage.rules b/storage.rules index 0516517f..8f6ed2b8 100644 --- a/storage.rules +++ b/storage.rules @@ -2,17 +2,21 @@ rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /tanam-users/{uid} { - match /{allPaths=**} { + match /{allPaths=**} { allow read: if request.auth != null && request.auth.uid == uid; } - match /profile.png { + match /profile.png { allow read: if request.auth != null; } match /profile-picture-new { allow create: if request.auth != null && request.auth.uid == uid; } + + match /new-profile-image { + allow create: if request.auth != null && request.auth.uid == uid; + } } } }