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}

    } +
    + + + +
    +
    + + + ); + } + + return ( +
    +
    +
    +
    + +
    {profileUser.name || "—"}
    +
    + +
    + +
    {profileUser.totalHours || 0}
    +
    + + {profileUser.isProfilePublic ? ( + <> +
    + +
    {profileUser.age || "—"}
    +
    + +
    + +
    + {profileUser.interests || "—"} +
    +
    + + ) : ( +

    + This user has not made their age and interests public. +

    + )} + +
    + +
    + #{profileUser._id ? profileUser._id.slice(-4) : "—"} +
    +
    + +
    + +
    +
    +
    +
    + ); +} + +export default PublicProfile; diff --git a/packages/frontend/src/main.jsx b/packages/frontend/src/main.jsx index 2c15b51..f1de1d6 100644 --- a/packages/frontend/src/main.jsx +++ b/packages/frontend/src/main.jsx @@ -14,6 +14,7 @@ import RequireAuth from "./components/RequireAuth.jsx"; import Profile from "./components/Profile.jsx"; import CreateGroup from "./components/CreateGroup.jsx"; import GroupDetail from "./components/GroupDetail.jsx"; +import PublicProfile from "./components/PublicProfile.jsx"; const root = document.getElementById("root"); @@ -65,6 +66,14 @@ ReactDOM.createRoot(root).render( } /> + + + + } + /> , );