diff --git a/apps/web/app/challenges/[id]/settings/page.tsx b/apps/web/app/challenges/[id]/settings/page.tsx new file mode 100644 index 00000000..4e16e889 --- /dev/null +++ b/apps/web/app/challenges/[id]/settings/page.tsx @@ -0,0 +1,60 @@ +import { notFound, redirect } from "next/navigation"; +import { getConvexClient } from "@/lib/convex-server"; +import { api } from "@repo/backend"; +import type { Id } from "@repo/backend/_generated/dataModel"; + +import { getCurrentUser } from "@/lib/auth"; +import { isAuthenticated } from "@/lib/server-auth"; +import { DashboardLayoutWrapper } from "../notifications/dashboard-layout-wrapper"; +import { SettingsContent } from "./settings-content"; + +interface SettingsPageProps { + params: Promise<{ id: string }>; +} + +export default async function SettingsPage({ params }: SettingsPageProps) { + const convex = getConvexClient(); + const [currentUser, { id }] = await Promise.all([ + getCurrentUser(), + params, + ]); + + if (!currentUser) { + const authenticated = await isAuthenticated(); + if (authenticated) { + redirect(`/challenges/${id}`); + } + redirect(`/sign-in?redirect_url=/challenges/${id}/settings`); + } + + const challengeId = id as Id<"challenges">; + + const challenge = await convex.query(api.queries.challenges.getById, { + challengeId, + }); + + if (!challenge) { + notFound(); + } + + return ( + +
+ +
+
+ ); +} diff --git a/apps/web/app/challenges/[id]/settings/settings-content.tsx b/apps/web/app/challenges/[id]/settings/settings-content.tsx new file mode 100644 index 00000000..6dfdeddd --- /dev/null +++ b/apps/web/app/challenges/[id]/settings/settings-content.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery } from "convex/react"; +import { api } from "@repo/backend"; +import type { Id, Doc } from "@repo/backend/_generated/dataModel"; +import { Loader2, Settings, User, List, Check } from "lucide-react"; + +import { UserAvatar } from "@/components/user-avatar"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface SettingsContentProps { + currentUser: { + _id: Id<"users">; + username: string; + name?: string; + email: string; + avatarUrl?: string; + }; + currentChallengeId: Id<"challenges">; +} + +export function SettingsContent({ + currentUser, + currentChallengeId, +}: SettingsContentProps) { + const router = useRouter(); + const [isUpdating, setIsUpdating] = useState(false); + const [updateSuccess, setUpdateSuccess] = useState(false); + const [updateError, setUpdateError] = useState(null); + + // Form state + const [name, setName] = useState(currentUser.name ?? ""); + const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl ?? ""); + + // Fetch user's challenges + const userChallenges = useQuery(api.queries.participations.getUserChallenges, { + userId: currentUser._id, + }); + + const updateUser = useMutation(api.mutations.users.updateUser); + + const handleSaveProfile = async () => { + if (isUpdating) return; + setIsUpdating(true); + setUpdateSuccess(false); + setUpdateError(null); + + try { + await updateUser({ + userId: currentUser._id, + name: name.trim() || undefined, + avatarUrl: avatarUrl.trim() || undefined, + }); + + setUpdateSuccess(true); + // Refresh the page to show updated data + router.refresh(); + } catch (error) { + console.error("Failed to update profile:", error); + setUpdateError("Failed to update your profile. Please try again."); + } finally { + setIsUpdating(false); + } + }; + + return ( +
+ {/* Header */} +
+

+ + Settings +

+

+ Manage your profile and preferences +

+
+ + {/* Profile Settings */} + + + + + Profile Information + + + Update your profile details + + + + {/* Avatar Preview */} +
+ +
+

@{currentUser.username}

+

{currentUser.email}

+
+
+ + {/* Name Field */} +
+ + setName(e.target.value)} + placeholder="Your name" + /> +
+ + {/* Avatar URL Field */} +
+ + setAvatarUrl(e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> +

+ Enter a direct link to an image +

+
+ + {/* Save Button */} +
+ + {updateSuccess && ( +

Profile updated successfully!

+ )} + {updateError && ( +

{updateError}

+ )} +
+
+
+ + {/* Challenge Switcher */} + + + + + Your Challenges + + + Switch between challenges you're participating in + + + + {userChallenges === undefined ? ( +
+ +
+ ) : userChallenges && userChallenges.length > 0 ? ( +
+ {userChallenges.map((challenge: Doc<"challenges">) => { + const isCurrent = challenge._id === currentChallengeId; + return ( + +
+

{challenge.name}

+

+ {new Date(challenge.startDate).toLocaleDateString()} -{" "} + {new Date(challenge.endDate).toLocaleDateString()} +

+
+ {isCurrent && ( + + )} + + ); + })} +
+ ) : ( +

+ You're not participating in any challenges yet. +

+ )} + +
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx b/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx index 27ecf47e..ef2757bf 100644 --- a/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx +++ b/apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx @@ -12,6 +12,7 @@ import { Flame, Loader2, Medal, + Settings, Trophy, UserMinus, UserPlus, @@ -129,29 +130,43 @@ export function UserProfileContent({

@{user.username}

- {/* Follow Button */} - {followData && !followData.isOwnProfile && ( - + {/* Settings Button (own profile) or Follow Button */} + {followData && ( + followData.isOwnProfile ? ( + + ) : ( + + ) )} diff --git a/packages/backend/queries/participations.ts b/packages/backend/queries/participations.ts index 01c59208..b872cd98 100644 --- a/packages/backend/queries/participations.ts +++ b/packages/backend/queries/participations.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "../lib/ids"; import { getChallengeWeekNumber, getWeekDateRange, getTotalWeeks } from "../lib/weeks"; import type { Id } from "../_generated/dataModel"; import { notDeleted } from "../lib/activityFilters"; +import { dateOnlyToUtcMs } from "../lib/dateOnly"; /** * Get recent participants for a challenge @@ -247,6 +248,36 @@ export const getMentionable = query({ } }); +/** + * Get all challenges a user is participating in + */ +export const getUserChallenges = query({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const participations = await ctx.db + .query("userChallenges") + .withIndex("userId", (q) => q.eq("userId", args.userId)) + .collect(); + + const challenges = await Promise.all( + participations.map(async (p) => { + const challenge = await ctx.db.get(p.challengeId); + return challenge; + }) + ); + + return challenges + .filter((c): c is NonNullable => c !== null) + .sort((a, b) => { + const aDate = typeof a.startDate === "string" ? dateOnlyToUtcMs(a.startDate) : a.startDate; + const bDate = typeof b.startDate === "string" ? dateOnlyToUtcMs(b.startDate) : b.startDate; + return bDate - aDate; + }); // Sort by start date descending (most recent first) + }, +}); + /** * Get count of participants in a challenge */