diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e455fdc..c9dd8c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "react-currency-input-field": "^4.0.3", "react-datepicker": "^8.2.1", "react-dom": "^18.3.1", + "react-easy-crop": "^5.5.6", "react-icons": "^5.4.0", "react-router-dom": "^6.26.2", "react-transition-group": "^4.4.5", @@ -10349,6 +10350,11 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==" + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -11243,6 +11249,19 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-crop": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz", + "integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8f8cbd1..c0e5558 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "react-currency-input-field": "^4.0.3", "react-datepicker": "^8.2.1", "react-dom": "^18.3.1", + "react-easy-crop": "^5.5.6", "react-icons": "^5.4.0", "react-router-dom": "^6.26.2", "react-transition-group": "^4.4.5", @@ -54,11 +55,11 @@ "globals": "^15.9.0", "jsdom": "^25.0.1", "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.8", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^2.1.8", - "tailwindcss": "^3.4.17" + "vitest": "^2.1.8" } } diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx new file mode 100644 index 0000000..5d908c3 --- /dev/null +++ b/frontend/src/components/Avatar.tsx @@ -0,0 +1,26 @@ +import { useState } from "react"; + +type AvatarProps = { + src: string | null | undefined; + alt: string; + className?: string; + fallbackSrc: string; +}; + +/** + * Renders a profile image with fallback when the URL fails to load. + * Use for profile pictures and avatars across the app. + */ +export default function Avatar({ src, alt, className = "", fallbackSrc }: AvatarProps) { + const [imgError, setImgError] = useState(false); + const effectiveSrc = src && !imgError ? src : fallbackSrc; + + return ( + {alt} setImgError(true)} + /> + ); +} diff --git a/frontend/src/main-page/grants/grant-view/ContactCard.tsx b/frontend/src/main-page/grants/grant-view/ContactCard.tsx index c65505f..d565a39 100644 --- a/frontend/src/main-page/grants/grant-view/ContactCard.tsx +++ b/frontend/src/main-page/grants/grant-view/ContactCard.tsx @@ -1,5 +1,6 @@ import { getAppStore } from "../../../external/bcanSatchel/store"; import POC from "../../../../../middle-layer/types/POC"; +import Avatar from "../../../components/Avatar"; import logo from "../../../images/logo.svg"; type ContactCardProps = { @@ -20,10 +21,11 @@ const contactPhoto = return (
- Profile

diff --git a/frontend/src/main-page/settings/ProfilePictureModal.tsx b/frontend/src/main-page/settings/ProfilePictureModal.tsx new file mode 100644 index 0000000..24725c7 --- /dev/null +++ b/frontend/src/main-page/settings/ProfilePictureModal.tsx @@ -0,0 +1,262 @@ +import { useState, useCallback } from "react"; +import Cropper, { Area } from "react-easy-crop"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import Button from "../../components/Button"; +import { getCroppedImg } from "./cropUtils"; +import { + ALLOWED_PROFILE_PIC_MIME_TYPES, + ALLOWED_PROFILE_PIC_EXTENSIONS, + MAX_PROFILE_PIC_SIZE_BYTES, + MAX_PROFILE_PIC_SIZE_MB, +} from "./profilePictureConstants"; +import { api } from "../../api"; +import { getAppStore } from "../../external/bcanSatchel/store"; +import { updateUserProfile } from "../../external/bcanSatchel/actions"; +import { setActiveUsers } from "../../external/bcanSatchel/actions"; +import { User } from "../../../../middle-layer/types/User"; + +type ProfilePictureModalProps = { + isOpen: boolean; + onClose: () => void; + onSuccess?: () => void; + onError?: (message: string) => void; +}; + +export default function ProfilePictureModal({ + isOpen, + onClose, + onSuccess, + onError, +}: ProfilePictureModalProps) { + const [imageSrc, setImageSrc] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1.6); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [uploadError, setUploadError] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [validationError, setValidationError] = useState(null); + + const user = getAppStore().user; + + const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels); + }, []); + + const handleClose = () => { + setImageSrc(null); + setCrop({ x: 0, y: 0 }); + setZoom(1.6); + setCroppedAreaPixels(null); + setUploadError(null); + setValidationError(null); + setIsUploading(false); + onClose(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + + setValidationError(null); + setUploadError(null); + + if (!file) return; + + if (!ALLOWED_PROFILE_PIC_MIME_TYPES.includes(file.type as (typeof ALLOWED_PROFILE_PIC_MIME_TYPES)[number])) { + setValidationError( + `Invalid file type. Allowed: ${ALLOWED_PROFILE_PIC_EXTENSIONS.join(", ")}` + ); + return; + } + + if (file.size > MAX_PROFILE_PIC_SIZE_BYTES) { + setValidationError(`File too large. Maximum size is ${MAX_PROFILE_PIC_SIZE_MB} MB.`); + return; + } + + const reader = new FileReader(); + reader.addEventListener("load", () => { + setImageSrc(reader.result as string); + }); + reader.readAsDataURL(file); + }; + + const handleSave = async () => { + if (!imageSrc || !croppedAreaPixels || !user) return; + + setIsUploading(true); + setUploadError(null); + + try { + const blob = await getCroppedImg(imageSrc, croppedAreaPixels); + const formData = new FormData(); + formData.append("profilePic", blob, "profilepic.jpg"); + formData.append( + "user", + JSON.stringify({ + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + position: user.position, + }) + ); + + const response = await api("/user/upload-pfp", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + const message = + (errBody as { message?: string }).message || + `Upload failed (${response.status})`; + throw new Error(message); + } + + const raw = await response.text(); + let url: string; + try { + const parsed = JSON.parse(raw); + url = typeof parsed === "string" ? parsed : String(parsed); + } catch { + url = raw.replace(/^"|"$/g, "").trim(); + } + + updateUserProfile({ ...user, profilePicUrl: url }); + + const store = getAppStore(); + const updatedActiveUsers = (store.activeUsers || []).map((u: User) => + u.email === user.email ? { ...u, profilePicUrl: url } : u + ); + setActiveUsers(updatedActiveUsers); + + handleClose(); + onSuccess?.(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to upload profile picture."; + setUploadError(message); + onError?.(message); + } finally { + setIsUploading(false); + } + }; + + if (!isOpen) return null; + + return ( +

+
+
+

+ Profile Picture +

+ +
+ + {!imageSrc ? ( +
+ + {validationError && ( +
+ {validationError} +
+ )} +
+ ) : ( + <> +
+ +
+ +
+ + setZoom(Number(e.target.value))} + className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-grey-300 accent-primary-900" + /> +
{zoom.toFixed(1)}x
+
+ + {(uploadError || validationError) && ( +
+ {uploadError ?? validationError} +
+ )} + +
+
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/main-page/settings/Settings.tsx b/frontend/src/main-page/settings/Settings.tsx index e296464..5807519 100644 --- a/frontend/src/main-page/settings/Settings.tsx +++ b/frontend/src/main-page/settings/Settings.tsx @@ -1,25 +1,39 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { observer } from "mobx-react-lite"; import Button from "../../components/Button"; import InfoCard from "./components/InfoCard"; +import Avatar from "../../components/Avatar"; import logo from "../../images/logo.svg"; import { faPenToSquare } from "@fortawesome/free-solid-svg-icons"; import ChangePasswordModal from "./ChangePasswordModal"; +import ProfilePictureModal from "./ProfilePictureModal"; +import { getAppStore } from "../../external/bcanSatchel/store"; +import { ALLOWED_PROFILE_PIC_EXTENSIONS, MAX_PROFILE_PIC_SIZE_MB } from "./profilePictureConstants"; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const initialPersonalInfo = { - firstName: "John", - lastName: "Doe", - email: "john.doe@gmail.com", -}; +function Settings() { + const user = getAppStore().user; + const personalInfoFromUser = user + ? { firstName: user.firstName, lastName: user.lastName, email: user.email } + : { firstName: "", lastName: "", email: "" }; -export default function Settings() { - const [personalInfo, setPersonalInfo] = useState(initialPersonalInfo); + const [personalInfo, setPersonalInfo] = useState(personalInfoFromUser); const [isEditingPersonalInfo, setIsEditingPersonalInfo] = useState(false); - const [editForm, setEditForm] = useState(initialPersonalInfo); + const [editForm, setEditForm] = useState(personalInfoFromUser); const [personalInfoError, setPersonalInfoError] = useState(null); const [isChangePasswordModalOpen, setIsChangePasswordModalOpen] = useState(false); const [changePasswordError, setChangePasswordError] = useState(null); + const [isProfilePictureModalOpen, setIsProfilePictureModalOpen] = useState(false); + const [profilePictureMessage, setProfilePictureMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + useEffect(() => { + if (user) { + const next = { firstName: user.firstName, lastName: user.lastName, email: user.email }; + setPersonalInfo((prev) => (prev.email === user.email ? prev : next)); + setEditForm((prev) => (prev.email === user.email ? prev : next)); + } + }, [user]); const handleStartEdit = () => { setEditForm(personalInfo); @@ -50,34 +64,56 @@ export default function Settings() {
- Profile

Profile Picture

+ {profilePictureMessage && ( +
+ {profilePictureMessage.text} +
+ )}

- We support PNGs, JPEGs, and PDFs under 10 MB + {ALLOWED_PROFILE_PIC_EXTENSIONS.join(", ")} up to {MAX_PROFILE_PIC_SIZE_MB} MB

+ setIsProfilePictureModalOpen(false)} + onSuccess={() => setProfilePictureMessage({ type: "success", text: "Profile picture updated." })} + onError={(msg) => setProfilePictureMessage({ type: "error", text: msg })} + /> + setIsChangePasswordModalOpen(false)} error={changePasswordError} onSubmit={(values) => { - // Backend: call API with values.currentPassword and values.newPassword void values; }} />
); } + +export default observer(Settings); diff --git a/frontend/src/main-page/settings/cropUtils.ts b/frontend/src/main-page/settings/cropUtils.ts new file mode 100644 index 0000000..d0a534c --- /dev/null +++ b/frontend/src/main-page/settings/cropUtils.ts @@ -0,0 +1,55 @@ +/** + * Creates a cropped image blob from the source image and crop area. + * Used with react-easy-crop's onCropComplete (croppedAreaPixels). + */ +export async function getCroppedImg( + imageSrc: string, + pixelCrop: { x: number; y: number; width: number; height: number } +): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("No 2d context"); + } + + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error("Canvas toBlob failed")); + return; + } + resolve(blob); + }, + "image/jpeg", + 0.92 + ); + }); +} + +function createImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + image.setAttribute("crossOrigin", "anonymous"); + image.src = url; + }); +} diff --git a/frontend/src/main-page/settings/profilePictureConstants.ts b/frontend/src/main-page/settings/profilePictureConstants.ts new file mode 100644 index 0000000..59165e3 --- /dev/null +++ b/frontend/src/main-page/settings/profilePictureConstants.ts @@ -0,0 +1,16 @@ +/** Allowed MIME types for profile picture upload (must match backend). */ +export const ALLOWED_PROFILE_PIC_MIME_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +] as const; + +/** Allowed file extensions for display in UI. */ +export const ALLOWED_PROFILE_PIC_EXTENSIONS = ["JPG", "JPEG", "PNG", "GIF", "WEBP"]; + +/** Max file size: 5MB (backend limit). */ +export const MAX_PROFILE_PIC_SIZE_BYTES = 5 * 1024 * 1024; + +export const MAX_PROFILE_PIC_SIZE_MB = 5; diff --git a/frontend/src/main-page/users/user-rows/UserRow.tsx b/frontend/src/main-page/users/user-rows/UserRow.tsx index 1cd1d96..481cf90 100644 --- a/frontend/src/main-page/users/user-rows/UserRow.tsx +++ b/frontend/src/main-page/users/user-rows/UserRow.tsx @@ -1,5 +1,6 @@ import { User } from "../../../../../middle-layer/types/User"; import UserPositionCard from "./UserPositionCard"; +import Avatar from "../../../components/Avatar"; import logo from "../../../images/logo.svg"; interface UserRowProps { @@ -13,10 +14,11 @@ const UserRow = ({ user, action }: UserRowProps) => { className="grid grid-cols-2 md:grid-cols-[30%_35%_25%_10%] cols gap-2 md:gap-0 text-sm lg:text-base border-b-2 border-grey-150 py-4 px-8 items-center" >
- Profile {user.firstName} {user.lastName}