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 (
+ 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 (
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
- We support PNGs, JPEGs, and PDFs under 10 MB
+ {ALLOWED_PROFILE_PIC_EXTENSIONS.join(", ")} up to {MAX_PROFILE_PIC_SIZE_MB} MB
+ Profile Picture
+
+
+
Profile Picture
+ {profilePictureMessage && (
+