Skip to content

Commit 1b7852d

Browse files
author
Anonymous Admin
committed
Add settings page with profile editing and challenge switching
1 parent e63aea6 commit 1b7852d

4 files changed

Lines changed: 353 additions & 23 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { notFound, redirect } from "next/navigation";
2+
import { getConvexClient } from "@/lib/convex-server";
3+
import { api } from "@repo/backend";
4+
import type { Id } from "@repo/backend/_generated/dataModel";
5+
6+
import { getCurrentUser } from "@/lib/auth";
7+
import { isAuthenticated } from "@/lib/server-auth";
8+
import { DashboardLayoutWrapper } from "../../notifications/dashboard-layout-wrapper";
9+
import { SettingsContent } from "./settings-content";
10+
11+
interface SettingsPageProps {
12+
params: Promise<{ id: string }>;
13+
}
14+
15+
export default async function SettingsPage({ params }: SettingsPageProps) {
16+
const convex = getConvexClient();
17+
const [currentUser, { id }] = await Promise.all([
18+
getCurrentUser(),
19+
params,
20+
]);
21+
22+
if (!currentUser) {
23+
const authenticated = await isAuthenticated();
24+
if (authenticated) {
25+
redirect(`/challenges/${id}`);
26+
}
27+
redirect(`/sign-in?redirect_url=/challenges/${id}/settings`);
28+
}
29+
30+
const challengeId = id as Id<"challenges">;
31+
32+
const challenge = await convex.query(api.queries.challenges.getById, {
33+
challengeId,
34+
});
35+
36+
if (!challenge) {
37+
notFound();
38+
}
39+
40+
return (
41+
<DashboardLayoutWrapper
42+
challenge={{
43+
id: challenge._id,
44+
name: challenge.name,
45+
startDate: challenge.startDate,
46+
endDate: challenge.endDate,
47+
}}
48+
currentUserId={currentUser._id}
49+
currentUser={currentUser}
50+
hideRightSidebar
51+
>
52+
<div className="mx-auto max-w-2xl px-4 py-6">
53+
<SettingsContent
54+
currentUser={currentUser}
55+
currentChallengeId={challenge._id}
56+
/>
57+
</div>
58+
</DashboardLayoutWrapper>
59+
);
60+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import Link from "next/link";
5+
import { useRouter } from "next/navigation";
6+
import { useMutation, useQuery } from "convex/react";
7+
import { api } from "@repo/backend";
8+
import type { Id } from "@repo/backend/_generated/dataModel";
9+
import { Loader2, Settings, User, List, Check } from "lucide-react";
10+
11+
import { UserAvatar } from "@/components/user-avatar";
12+
import { Button } from "@/components/ui/button";
13+
import {
14+
Card,
15+
CardContent,
16+
CardDescription,
17+
CardHeader,
18+
CardTitle,
19+
} from "@/components/ui/card";
20+
import { Input } from "@/components/ui/input";
21+
import { Label } from "@/components/ui/label";
22+
import { useToast } from "@/hooks/use-toast";
23+
24+
interface SettingsContentProps {
25+
currentUser: {
26+
_id: Id<"users">;
27+
username: string;
28+
name?: string;
29+
email: string;
30+
avatarUrl?: string;
31+
};
32+
currentChallengeId: Id<"challenges">;
33+
}
34+
35+
export function SettingsContent({
36+
currentUser,
37+
currentChallengeId,
38+
}: SettingsContentProps) {
39+
const router = useRouter();
40+
const { toast } = useToast();
41+
const [isUpdating, setIsUpdating] = useState(false);
42+
43+
// Form state
44+
const [name, setName] = useState(currentUser.name ?? "");
45+
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl ?? "");
46+
47+
// Fetch user's challenges
48+
const userChallenges = useQuery(api.queries.participations.getUserChallenges, {
49+
userId: currentUser._id,
50+
});
51+
52+
const updateUser = useMutation(api.mutations.users.updateUser);
53+
54+
const handleSaveProfile = async () => {
55+
if (isUpdating) return;
56+
setIsUpdating(true);
57+
58+
try {
59+
await updateUser({
60+
userId: currentUser._id,
61+
name: name.trim() || undefined,
62+
avatarUrl: avatarUrl.trim() || undefined,
63+
});
64+
65+
toast({
66+
title: "Profile updated",
67+
description: "Your profile has been updated successfully.",
68+
});
69+
70+
// Refresh the page to show updated data
71+
router.refresh();
72+
} catch (error) {
73+
console.error("Failed to update profile:", error);
74+
toast({
75+
title: "Update failed",
76+
description: "Failed to update your profile. Please try again.",
77+
variant: "destructive",
78+
});
79+
} finally {
80+
setIsUpdating(false);
81+
}
82+
};
83+
84+
return (
85+
<div className="space-y-6">
86+
{/* Header */}
87+
<div>
88+
<h1 className="text-3xl font-bold flex items-center gap-2">
89+
<Settings className="h-8 w-8" />
90+
Settings
91+
</h1>
92+
<p className="text-muted-foreground mt-1">
93+
Manage your profile and preferences
94+
</p>
95+
</div>
96+
97+
{/* Profile Settings */}
98+
<Card>
99+
<CardHeader>
100+
<CardTitle className="flex items-center gap-2">
101+
<User className="h-5 w-5" />
102+
Profile Information
103+
</CardTitle>
104+
<CardDescription>
105+
Update your profile details
106+
</CardDescription>
107+
</CardHeader>
108+
<CardContent className="space-y-4">
109+
{/* Avatar Preview */}
110+
<div className="flex items-center gap-4">
111+
<UserAvatar
112+
user={{
113+
username: currentUser.username,
114+
name: name || currentUser.name,
115+
avatarUrl: avatarUrl || currentUser.avatarUrl,
116+
}}
117+
size="xl"
118+
/>
119+
<div className="text-sm text-muted-foreground">
120+
<p className="font-medium">@{currentUser.username}</p>
121+
<p>{currentUser.email}</p>
122+
</div>
123+
</div>
124+
125+
{/* Name Field */}
126+
<div className="space-y-2">
127+
<Label htmlFor="name">Display Name</Label>
128+
<Input
129+
id="name"
130+
type="text"
131+
value={name}
132+
onChange={(e) => setName(e.target.value)}
133+
placeholder="Your name"
134+
/>
135+
</div>
136+
137+
{/* Avatar URL Field */}
138+
<div className="space-y-2">
139+
<Label htmlFor="avatarUrl">Avatar URL</Label>
140+
<Input
141+
id="avatarUrl"
142+
type="url"
143+
value={avatarUrl}
144+
onChange={(e) => setAvatarUrl(e.target.value)}
145+
placeholder="https://example.com/avatar.jpg"
146+
/>
147+
<p className="text-xs text-muted-foreground">
148+
Enter a direct link to an image
149+
</p>
150+
</div>
151+
152+
{/* Save Button */}
153+
<Button
154+
onClick={handleSaveProfile}
155+
disabled={isUpdating}
156+
className="w-full sm:w-auto"
157+
>
158+
{isUpdating ? (
159+
<>
160+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
161+
Saving...
162+
</>
163+
) : (
164+
"Save Changes"
165+
)}
166+
</Button>
167+
</CardContent>
168+
</Card>
169+
170+
{/* Challenge Switcher */}
171+
<Card>
172+
<CardHeader>
173+
<CardTitle className="flex items-center gap-2">
174+
<List className="h-5 w-5" />
175+
Your Challenges
176+
</CardTitle>
177+
<CardDescription>
178+
Switch between challenges you're participating in
179+
</CardDescription>
180+
</CardHeader>
181+
<CardContent>
182+
{userChallenges === undefined ? (
183+
<div className="flex items-center justify-center py-8">
184+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
185+
</div>
186+
) : userChallenges && userChallenges.length > 0 ? (
187+
<div className="space-y-2">
188+
{userChallenges.map((challenge) => {
189+
const isCurrent = challenge._id === currentChallengeId;
190+
return (
191+
<Link
192+
key={challenge._id}
193+
href={`/challenges/${challenge._id}/dashboard`}
194+
className={`flex items-center justify-between rounded-lg border p-4 transition-colors ${
195+
isCurrent
196+
? "border-primary bg-primary/5"
197+
: "hover:bg-muted/50"
198+
}`}
199+
>
200+
<div>
201+
<p className="font-medium">{challenge.name}</p>
202+
<p className="text-sm text-muted-foreground">
203+
{new Date(challenge.startDate).toLocaleDateString()} -{" "}
204+
{new Date(challenge.endDate).toLocaleDateString()}
205+
</p>
206+
</div>
207+
{isCurrent && (
208+
<Check className="h-5 w-5 text-primary" />
209+
)}
210+
</Link>
211+
);
212+
})}
213+
</div>
214+
) : (
215+
<p className="py-8 text-center text-sm text-muted-foreground">
216+
You're not participating in any challenges yet.
217+
</p>
218+
)}
219+
220+
<div className="mt-4">
221+
<Button variant="outline" asChild className="w-full">
222+
<Link href="/challenges">Browse all challenges</Link>
223+
</Button>
224+
</div>
225+
</CardContent>
226+
</Card>
227+
</div>
228+
);
229+
}

apps/web/app/challenges/[id]/users/[userId]/user-profile-content.tsx

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Flame,
1313
Loader2,
1414
Medal,
15+
Settings,
1516
Trophy,
1617
UserMinus,
1718
UserPlus,
@@ -129,29 +130,43 @@ export function UserProfileContent({
129130
<p className="text-muted-foreground">@{user.username}</p>
130131
</div>
131132

132-
{/* Follow Button */}
133-
{followData && !followData.isOwnProfile && (
134-
<Button
135-
variant={followData.isFollowing ? "outline" : "default"}
136-
size="sm"
137-
onClick={handleToggleFollow}
138-
disabled={isTogglingFollow}
139-
className="min-w-[100px]"
140-
>
141-
{isTogglingFollow ? (
142-
<Loader2 className="h-4 w-4 animate-spin" />
143-
) : followData.isFollowing ? (
144-
<>
145-
<UserMinus className="mr-2 h-4 w-4" />
146-
Unfollow
147-
</>
148-
) : (
149-
<>
150-
<UserPlus className="mr-2 h-4 w-4" />
151-
Follow
152-
</>
153-
)}
154-
</Button>
133+
{/* Settings Button (own profile) or Follow Button */}
134+
{followData && (
135+
followData.isOwnProfile ? (
136+
<Button
137+
variant="outline"
138+
size="sm"
139+
asChild
140+
className="min-w-[100px]"
141+
>
142+
<Link href={`/challenges/${challengeId}/settings`}>
143+
<Settings className="mr-2 h-4 w-4" />
144+
Settings
145+
</Link>
146+
</Button>
147+
) : (
148+
<Button
149+
variant={followData.isFollowing ? "outline" : "default"}
150+
size="sm"
151+
onClick={handleToggleFollow}
152+
disabled={isTogglingFollow}
153+
className="min-w-[100px]"
154+
>
155+
{isTogglingFollow ? (
156+
<Loader2 className="h-4 w-4 animate-spin" />
157+
) : followData.isFollowing ? (
158+
<>
159+
<UserMinus className="mr-2 h-4 w-4" />
160+
Unfollow
161+
</>
162+
) : (
163+
<>
164+
<UserPlus className="mr-2 h-4 w-4" />
165+
Follow
166+
</>
167+
)}
168+
</Button>
169+
)
155170
)}
156171
</div>
157172

packages/backend/queries/participations.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,32 @@ export const getMentionable = query({
247247
}
248248
});
249249

250+
/**
251+
* Get all challenges a user is participating in
252+
*/
253+
export const getUserChallenges = query({
254+
args: {
255+
userId: v.id("users"),
256+
},
257+
handler: async (ctx, args) => {
258+
const participations = await ctx.db
259+
.query("userChallenges")
260+
.withIndex("userId", (q) => q.eq("userId", args.userId))
261+
.collect();
262+
263+
const challenges = await Promise.all(
264+
participations.map(async (p) => {
265+
const challenge = await ctx.db.get(p.challengeId);
266+
return challenge;
267+
})
268+
);
269+
270+
return challenges
271+
.filter((c): c is NonNullable<typeof c> => c !== null)
272+
.sort((a, b) => b.startDate - a.startDate); // Sort by start date descending (most recent first)
273+
},
274+
});
275+
250276
/**
251277
* Get count of participants in a challenge
252278
*/

0 commit comments

Comments
 (0)