diff --git a/packages/backend/models/user.js b/packages/backend/models/user.js
index 43f1ace..bdf3585 100644
--- a/packages/backend/models/user.js
+++ b/packages/backend/models/user.js
@@ -140,6 +140,18 @@ const userSchema = new mongoose.Schema(
type: Number,
min: [0, "age cannot be negative"],
},
+
+ interests: {
+ type: String,
+ default: "",
+ trim: true,
+ },
+
+ profileVisibility: {
+ type: String,
+ enum: ["public", "groups", "private"],
+ default: "private",
+ },
},
{
timestamps: true,
diff --git a/packages/backend/routes/users.js b/packages/backend/routes/users.js
index 7fa5a31..77f612d 100644
--- a/packages/backend/routes/users.js
+++ b/packages/backend/routes/users.js
@@ -20,6 +20,9 @@ const allowedUserUpdateFields = [
"totalHours",
"weeklyHours",
"todayHours",
+ "interests",
+ "isProfilePublic",
+ "profileVisibility",
];
const commitmentGoals = {
@@ -167,6 +170,32 @@ router.get("/:userParam/groups", async (req, res) => {
}
});
+//get public profile for one user
+router.get("/:userParam/public", requireAuth, async (req, res) => {
+ try {
+ const user = await User.findOne(getUserFilter(req.params.userParam)).select(
+ "name age interests isProfilePublic totalHours",
+ );
+
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ return res.json({
+ user: {
+ _id: user._id,
+ name: user.name,
+ totalHours: user.totalHours || 0,
+ isProfilePublic: user.isProfilePublic,
+ age: user.isProfilePublic ? user.age || null : null,
+ interests: user.isProfilePublic ? user.interests || "" : "",
+ },
+ });
+ } catch (error) {
+ return sendError(res, error);
+ }
+});
+
//get one user by id or email
router.get("/:userParam", async (req, res) => {
try {
diff --git a/packages/frontend/src/components/GroupDetail.css b/packages/frontend/src/components/GroupDetail.css
index cfa39ad..3238ae6 100644
--- a/packages/frontend/src/components/GroupDetail.css
+++ b/packages/frontend/src/components/GroupDetail.css
@@ -229,31 +229,108 @@
}
.groupMemberItem {
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ align-items: center;
+ gap: 10px;
+}
+
+.groupMemberLeft {
display: flex;
align-items: center;
- gap: 12px;
+ justify-content: center;
+ width: 44px;
+ min-width: 44px;
+}
+
+.groupMemberBubble {
+ position: relative;
+ display: flex;
+ align-items: center;
+ min-height: 58px;
padding: 12px 18px;
border-radius: 999px;
border: 1.5px solid rgba(0, 8, 7, 0.16);
background: rgba(255, 255, 255, 0.42);
}
+.groupMemberBubbleSpacer {
+ display: none;
+}
+/*offset name left by half the button column so it centered*/
.groupMemberName {
- flex: 1;
+ position: absolute;
+ left: calc(60% - 95px); transform: translateX(-50%);
font-family: Georgia, "Times New Roman", serif;
font-size: 1rem;
color: var(--brand-black);
text-align: center;
+ white-space: nowrap;
+ max-width: 45%;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.leaderBadge {
+.groupMemberActions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ z-index: 2;
+}
+
+.leaderCrown {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.35rem;
+ cursor: default;
+ width: 36px;
+ height: 36px;
+}
+
+.leaderCrown::after {
+ content: "Leader";
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--brand-black);
+ color: var(--white);
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 0.8rem;
+ font-weight: 700;
+ padding: 5px 10px;
+ border-radius: 999px;
+ opacity: 0;
+ pointer-events: none;
+ white-space: nowrap;
+ transition: opacity 0.18s ease;
+ z-index: 20;
+}
+
+.leaderCrown:hover::after {
+ opacity: 1;
+}
+
+.viewProfileBtn {
background: var(--accent-purple, #9b9ece);
color: var(--brand-black);
+ border: none;
+ border-radius: 999px;
+ padding: 6px 16px;
font-family: Georgia, "Times New Roman", serif;
- font-size: 0.85rem;
+ font-size: 0.9rem;
font-weight: 700;
- padding: 4px 12px;
- border-radius: 999px;
+ cursor: pointer;
+ transition: opacity 0.18s ease;
+ white-space: nowrap;
+}
+
+.viewProfileBtn:hover {
+ opacity: 0.85;
}
.selfBadge {
@@ -279,7 +356,6 @@
.kickBtn:hover {
opacity: 0.85;
}
-
/* Messages */
.groupDetailMessage {
font-family: Georgia, "Times New Roman", serif;
@@ -357,6 +433,33 @@
}
.groupMemberItem {
+ grid-template-columns: 1fr;
+ gap: 6px;
+ }
+
+ .groupMemberLeft {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .groupMemberBubble {
+ border-radius: 24px;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .groupMemberName {
+ position: static;
+ transform: none;
+ max-width: 100%;
+ }
+
+ .groupMemberActions {
+ margin-left: 0;
flex-wrap: wrap;
+ justify-content: center;
}
+
+
+
}
\ No newline at end of file
diff --git a/packages/frontend/src/components/GroupDetail.jsx b/packages/frontend/src/components/GroupDetail.jsx
index 4604a41..f7ffccb 100644
--- a/packages/frontend/src/components/GroupDetail.jsx
+++ b/packages/frontend/src/components/GroupDetail.jsx
@@ -255,24 +255,50 @@ function GroupDetail() {
return (
-
- {member.name || member.email || memberId}
-
-
- {isLeader && Leader}
-
- {isOwner && !isLeader && (
-
- )}
-
- {isSelf && !isLeader && (
- You
- )}
+
+ {isLeader && (
+
+ 👑
+
+ )}
+
+
+
+
+
+
+ {member.name || member.email || memberId}
+
+
+
+
+
+ {isOwner && !isLeader && (
+
+ )}
+
+ {isSelf && !isLeader && (
+ You
+ )}
+
+
);
})}
diff --git a/packages/frontend/src/components/Profile.css b/packages/frontend/src/components/Profile.css
index 028a841..5830ad2 100644
--- a/packages/frontend/src/components/Profile.css
+++ b/packages/frontend/src/components/Profile.css
@@ -56,6 +56,72 @@
font-size: clamp(1rem, 1.2vw, 1.35rem);
}
+.profileInput {
+ width: 100%;
+ box-sizing: border-box;
+ outline: none;
+}
+
+.profileInput:focus {
+ border-color: var(--accent-purple, #9b9ece);
+}
+
+.profileInput[type="number"]::-webkit-outer-spin-button,
+.profileInput[type="number"]::-webkit-inner-spin-button {
+ appearance: none;
+ margin: 0;
+}
+
+.profileInput[type="number"] {
+ appearance: textfield;
+}
+.profileVisibilitySection {
+ margin-top: 28px;
+ padding-top: 24px;
+ border-top: 1.5px solid rgba(0, 8, 7, 0.12);
+}
+
+.profileVisibilityTitle {
+ margin: 0 0 14px;
+ text-align: center;
+ font-family: "EB Garamond", serif;
+ font-size: clamp(1.3rem, 1.8vw, 1.8rem);
+ color: var(--brand-black);
+}
+
+.profileVisibilityOptions {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.profileVisibilityBtn {
+ border: 1.5px solid rgba(0, 8, 7, 0.18);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.42);
+ color: var(--brand-black);
+ padding: 10px 20px;
+ font-family: "EB Garamond", serif;
+ font-size: 1.05rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition:
+ background 0.18s ease,
+ color 0.18s ease,
+ transform 0.18s ease;
+}
+
+.profileVisibilityBtn:hover {
+ transform: translateY(-1px);
+}
+
+.profileVisibilityBtnSelected {
+ background: var(--accent-purple, #9b9ece);
+ color: var(--brand-black);
+ border-color: var(--accent-purple, #9b9ece);
+}
+
.profileButtons {
margin-top: 34px;
display: flex;
@@ -80,11 +146,19 @@
opacity: 0.92;
}
+.profileSave {
+ min-width: 150px;
+ min-height: 68px;
+ padding: 0 28px;
+ background: #9b1f1f;
+ font-size: 2rem;
+}
+
.profileLogout {
min-width: 150px;
min-height: 68px;
padding: 0 28px;
- background: #ef5a58;
+ background: #7f1d1d;
font-size: 2rem;
}
@@ -92,7 +166,7 @@
min-width: 130px;
min-height: 68px;
padding: 10px 18px;
- background: #a70b0b;
+ background: #5f0b0b;
font-size: 1rem;
line-height: 1.1;
}
@@ -135,11 +209,22 @@
padding: 0 16px;
}
+
+
+ .profileVisibilityOptions {
+ flex-direction: column;
+ }
+
+ .profileVisibilityBtn {
+ width: 100%;
+ }
+
.profileButtons {
flex-direction: column;
align-items: stretch;
}
+ .profileSave,
.profileLogout,
.profileDelete {
width: 100%;
@@ -151,4 +236,4 @@
.profileDelete {
line-height: 1.2;
}
-}
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/Profile.jsx b/packages/frontend/src/components/Profile.jsx
index b453ad4..72c8015 100644
--- a/packages/frontend/src/components/Profile.jsx
+++ b/packages/frontend/src/components/Profile.jsx
@@ -1,4 +1,4 @@
-//displays the user profile, logout, deletion
+// Displays the user profile, logout, deletion, and profile viewability settings.
import { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import "./Profile.css";
@@ -6,9 +6,30 @@ import "./Profile.css";
const AZURE_URL =
"https://goattimer-hgh5bxcub9hrdgha.centralus-01.azurewebsites.net";
+const PROFILE_VISIBILITY_OPTIONS = [
+ {
+ value: "public",
+ label: "Public",
+ },
+ {
+ value: "groups",
+ label: "People in my groups",
+ },
+ {
+ value: "private",
+ label: "Private",
+ },
+];
+
function Profile() {
const [user, setUser] = useState(null);
+ const [form, setForm] = useState({
+ age: "",
+ interests: "",
+ profileVisibility: "private",
+ });
const [loading, setLoading] = useState(true);
+ const [message, setMessage] = useState("");
const [error, setError] = useState(null);
const navigate = useNavigate();
@@ -20,6 +41,13 @@ function Profile() {
})
.then((data) => {
setUser(data.user);
+ setForm({
+ age: data.user.age || "",
+ interests: data.user.interests || "",
+ profileVisibility:
+ data.user.profileVisibility ||
+ (data.user.isProfilePublic ? "public" : "private"),
+ });
setLoading(false);
})
.catch((err) => {
@@ -28,6 +56,34 @@ function Profile() {
});
}, []);
+ function handleSaveProfile() {
+ if (!user) return;
+
+ setError("");
+ setMessage("");
+
+ fetch(`${AZURE_URL}/api/users/${user._id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ age: form.age ? Number(form.age) : undefined,
+ interests: form.interests,
+ profileVisibility: form.profileVisibility,
+ isProfilePublic: form.profileVisibility === "public",
+ }),
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error("Failed to update profile");
+ return res.json();
+ })
+ .then((data) => {
+ setUser(data);
+ setMessage("Profile updated.");
+ })
+ .catch((err) => setError(err.message));
+ }
+
function handleLogout() {
const confirmed = window.confirm("Are you sure you want to log out?");
if (!confirmed) return;
@@ -71,7 +127,7 @@ function Profile() {
);
}
- if (error || !user) {
+ if (error && !user) {
return (
@@ -109,17 +165,30 @@ function Profile() {
-
{user.age || "—"}
-
-
-
-
-
{user.group || "—"}
+
+ setForm((current) => ({ ...current, age: e.target.value }))
+ }
+ />
-
{user.interests || "—"}
+
+ setForm((current) => ({
+ ...current,
+ interests: e.target.value,
+ }))
+ }
+ />
@@ -129,13 +198,50 @@ function Profile() {
+
+
Profile Viewability
+
+
+ {PROFILE_VISIBILITY_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+ {message &&
{message}
}
+ {error &&
{error}
}
+
+
+
+