Skip to content

Commit 7ed0521

Browse files
prazgaitisclaude
andauthored
feat: sidebar redesign, media grid fix, avatar migration (#203)
* feat: add suggested follows to sidebar, remove leaderboard Add "People to watch" section using affinity-based suggestions. Shows why each user is suggested based on interaction history. Visible follow button with indigo pill styling. Remove duplicate Live Leaderboard from sidebar — already has a dedicated page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: show user location instead of "In your challenge" for suggestions Low-affinity suggestions now show the user's location as context. Added location field to getSuggestions query return type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * polish: redesign sidebar stats and onboarding — bolder hierarchy, fewer cards Status card: remove Card wrapper, hero streak/rank with accent colors, compact secondary stats, user points with vs-avg delta, compact formatting. Onboarding: strip Card nesting to bare section with simple expandable rows, indigo step indicators, tighter layout matching sidebar style. Reorder sidebar: People to watch → Stats → Mini-Games → Prep checklist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve 3-photo grid layout — fill available space Use aspect-[3/2] container with equal columns and row-span-2 for the first image. Eliminates dead space in the right column. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Supabase avatar migration query and mutation Add findUsersWithSupabaseAvatars query and patchUserAvatar mutation to support migrating avatar URLs from Supabase to Cloudinary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8e15536 commit 7ed0521

7 files changed

Lines changed: 241 additions & 142 deletions

File tree

Lines changed: 161 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
'use client';
22

3-
import { useEffect, useState } from 'react';
4-
import { Flame, Trophy, Users } from 'lucide-react';
3+
import { useCallback, useEffect, useState } from 'react';
4+
import { Calendar, Flame, Loader2, Trophy, Users } from 'lucide-react';
5+
import { useMutation, useQuery } from 'convex/react';
6+
import { api } from '@repo/backend';
7+
import type { Id } from '@repo/backend/_generated/dataModel';
58

69
import { useChallengeSummary } from './challenge-realtime-context';
710
import { UserAvatar } from '@/components/user-avatar';
8-
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
911
import { ActiveMiniGames } from '@/components/mini-games';
1012
import { OnboardingCard } from './onboarding-card';
1113
import { dateOnlyToUtcMs } from '@/lib/date-only';
12-
import { formatPoints } from '@/lib/points';
13-
import { cn } from '@/lib/utils';
14+
import { formatPointsCompact } from '@/lib/points';
1415

1516
interface ChallengeSidebarProps {
1617
challengeId: string;
@@ -20,7 +21,7 @@ interface ChallengeSidebarProps {
2021

2122
export function ChallengeSidebar({ challengeId, currentUserId, challengeStartDate }: ChallengeSidebarProps) {
2223
const { summary } = useChallengeSummary();
23-
const { stats, leaderboard } = summary;
24+
const { stats } = summary;
2425

2526
// Compute client-side only to avoid hydration mismatch (Date.now() differs server vs client)
2627
const [challengeStarted, setChallengeStarted] = useState(false);
@@ -30,103 +31,174 @@ export function ChallengeSidebar({ challengeId, currentUserId, challengeStartDat
3031

3132
return (
3233
<div className="space-y-4">
33-
<Card className="border-zinc-800 bg-transparent">
34-
<CardHeader className="flex flex-col space-y-2">
35-
<div className="text-xs uppercase tracking-wide text-zinc-500">
36-
Status
34+
<SuggestedFollows challengeId={challengeId} />
35+
36+
<div>
37+
<div className="mb-4">
38+
<div className="text-xs font-medium uppercase tracking-widest text-zinc-500">
39+
Your points
3740
</div>
38-
<CardTitle className="text-2xl font-bold text-white">
39-
{formatPoints(stats.totalPoints)} total points
40-
</CardTitle>
41-
</CardHeader>
42-
<CardContent className="grid grid-cols-2 gap-3 text-sm">
43-
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
44-
<div className="flex items-center justify-between text-zinc-500">
45-
<span>My streak</span>
46-
<Flame className="h-4 w-4" />
47-
</div>
48-
<p className="mt-2 text-xl font-semibold text-white">{stats.userStreak}</p>
41+
<div className="mt-1 flex items-baseline gap-2">
42+
<span className="font-mono text-3xl font-bold text-white">
43+
{formatPointsCompact(stats.userPoints)}
44+
</span>
45+
{stats.totalParticipants > 0 && (() => {
46+
const avg = stats.totalPoints / stats.totalParticipants;
47+
const delta = stats.userPoints - avg;
48+
const isAbove = delta >= 0;
49+
return (
50+
<span className={`font-mono text-xs ${isAbove ? 'text-green-400' : 'text-red-400'}`}>
51+
{isAbove ? '+' : ''}{formatPointsCompact(delta)} vs avg
52+
</span>
53+
);
54+
})()}
4955
</div>
50-
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
51-
<div className="flex items-center justify-between text-zinc-500">
52-
<span>Participants</span>
53-
<Users className="h-4 w-4" />
56+
</div>
57+
58+
{/* Streak & Rank — hero stats */}
59+
<div className="mb-3 grid grid-cols-2 gap-3">
60+
<div className="rounded-lg border border-orange-500/20 bg-orange-500/5 p-3">
61+
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-orange-400/70">
62+
<Flame className="h-3.5 w-3.5" />
63+
Streak
5464
</div>
55-
<p className="mt-2 text-xl font-semibold text-white">{stats.totalParticipants}</p>
56-
</div>
57-
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
58-
<div className="text-zinc-500">Days remaining</div>
59-
<p className="mt-2 text-xl font-semibold text-white">{stats.daysRemaining}</p>
65+
<p className="mt-1 font-mono text-2xl font-bold text-orange-400">
66+
{stats.userStreak}
67+
</p>
6068
</div>
61-
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
62-
<div className="text-zinc-500">Your rank</div>
63-
<p className="mt-2 text-xl font-semibold text-white">
69+
<div className="rounded-lg border border-indigo-500/20 bg-indigo-500/5 p-3">
70+
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-widest text-indigo-400/70">
71+
<Trophy className="h-3.5 w-3.5" />
72+
Rank
73+
</div>
74+
<p className="mt-1 font-mono text-2xl font-bold text-indigo-400">
6475
{stats.userRank ? `#${stats.userRank}` : '—'}
6576
</p>
6677
</div>
67-
</CardContent>
68-
</Card>
78+
</div>
79+
80+
{/* Secondary stats — dimmer, smaller */}
81+
<div className="grid grid-cols-2 gap-3">
82+
<div className="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2">
83+
<Users className="h-3.5 w-3.5 text-zinc-600" />
84+
<div>
85+
<span className="font-mono text-sm font-semibold text-zinc-300">{stats.totalParticipants}</span>
86+
<span className="ml-1.5 text-xs text-zinc-600">players</span>
87+
</div>
88+
</div>
89+
<div className="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 px-3 py-2">
90+
<Calendar className="h-3.5 w-3.5 text-zinc-600" />
91+
<div>
92+
<span className="font-mono text-sm font-semibold text-zinc-300">{stats.daysRemaining}</span>
93+
<span className="ml-1.5 text-xs text-zinc-600">days left</span>
94+
</div>
95+
</div>
96+
</div>
97+
</div>
6998

7099
{/* Active Mini-Games */}
71100
<ActiveMiniGames challengeId={challengeId} userId={currentUserId} />
72101

73-
<Card className="border-zinc-800 bg-transparent">
74-
<CardHeader>
75-
<CardTitle className="flex items-center gap-2 text-lg text-white">
76-
<Trophy className="h-5 w-5 text-amber-500" />
77-
Live Leaderboard
78-
</CardTitle>
79-
</CardHeader>
80-
<CardContent className="space-y-2">
81-
{leaderboard.length === 0 && (
82-
<p className="text-sm text-zinc-500">
83-
No participants have logged points yet.
84-
</p>
85-
)}
86-
{leaderboard.map((entry, index) => {
87-
const isCurrentUser = entry.participantId === currentUserId;
88-
return (
89-
<div
90-
key={entry.participantId}
91-
className={cn(
92-
'flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 transition',
93-
isCurrentUser && 'border-indigo-500/50 bg-indigo-500/10',
94-
)}
95-
>
96-
<div className="flex items-center gap-3">
97-
<span className="text-lg font-semibold text-zinc-500">
98-
#{index + 1}
99-
</span>
100-
<UserAvatar
101-
user={{
102-
id: entry.user.id,
103-
name: entry.user.name,
104-
username: entry.user.username,
105-
avatarUrl: entry.user.avatarUrl,
106-
}}
107-
challengeId={challengeId}
108-
size="md"
109-
showName
110-
>
111-
<p className="text-xs text-zinc-500">
112-
{formatPoints(entry.totalPoints)} pts · streak {entry.currentStreak}
113-
</p>
114-
</UserAvatar>
115-
</div>
116-
{isCurrentUser && (
117-
<span className="rounded-full bg-indigo-500 px-3 py-1 text-xs font-semibold text-white">
118-
You
119-
</span>
120-
)}
121-
</div>
122-
);
123-
})}
124-
</CardContent>
125-
</Card>
126-
127102
{challengeStarted && (
128103
<OnboardingCard challengeId={challengeId} userId={currentUserId} challengeStartDate={challengeStartDate} />
129104
)}
130105
</div>
131106
);
132107
}
108+
109+
function affinityLabel(score: number, location: string | null): string {
110+
if (score >= 40) return 'Active in your feed';
111+
if (score >= 15) return "You've interacted";
112+
if (location) return location;
113+
return '';
114+
}
115+
116+
function SuggestedFollows({ challengeId }: { challengeId: string }) {
117+
const suggestions = useQuery(api.queries.follows.getSuggestions, {
118+
challengeId: challengeId as Id<"challenges">,
119+
limit: 5,
120+
});
121+
122+
if (!suggestions || suggestions.length === 0) return null;
123+
124+
return (
125+
<div>
126+
<div className="mb-3 flex items-center justify-between">
127+
<h3 className="text-xs font-medium uppercase tracking-widest text-zinc-500">
128+
People to watch
129+
</h3>
130+
</div>
131+
<div className="space-y-1.5">
132+
{suggestions.map((user: { id: string; name: string | null; username: string; avatarUrl: string | null; location: string | null; affinityScore: number }) => (
133+
<SuggestionRow
134+
key={user.id}
135+
user={user}
136+
challengeId={challengeId}
137+
/>
138+
))}
139+
</div>
140+
</div>
141+
);
142+
}
143+
144+
function SuggestionRow({
145+
user,
146+
challengeId,
147+
}: {
148+
user: { id: string; name: string | null; username: string; avatarUrl: string | null; location: string | null; affinityScore: number };
149+
challengeId: string;
150+
}) {
151+
const [isToggling, setIsToggling] = useState(false);
152+
const toggleFollow = useMutation(api.mutations.follows.toggle);
153+
154+
const handleFollow = useCallback(
155+
async (e: React.MouseEvent) => {
156+
e.preventDefault();
157+
e.stopPropagation();
158+
if (isToggling) return;
159+
setIsToggling(true);
160+
try {
161+
await toggleFollow({ userId: user.id as Id<"users"> });
162+
} catch (error) {
163+
console.error("Failed to follow:", error);
164+
} finally {
165+
setIsToggling(false);
166+
}
167+
},
168+
[isToggling, toggleFollow, user.id],
169+
);
170+
171+
return (
172+
<div className="group flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-zinc-900/60">
173+
<UserAvatar
174+
user={{
175+
id: user.id,
176+
name: user.name,
177+
username: user.username,
178+
avatarUrl: user.avatarUrl,
179+
}}
180+
challengeId={challengeId}
181+
size="md"
182+
showName
183+
showUsername
184+
>
185+
{affinityLabel(user.affinityScore, user.location) && (
186+
<p className="text-[10px] text-zinc-600">
187+
{affinityLabel(user.affinityScore, user.location)}
188+
</p>
189+
)}
190+
</UserAvatar>
191+
<button
192+
onClick={handleFollow}
193+
disabled={isToggling}
194+
className="ml-auto shrink-0 rounded-full border border-indigo-500/60 px-3 py-1 text-xs font-semibold text-indigo-400 transition-all hover:bg-indigo-500 hover:text-white active:scale-95 disabled:opacity-50"
195+
>
196+
{isToggling ? (
197+
<Loader2 className="h-3 w-3 animate-spin" />
198+
) : (
199+
'Follow'
200+
)}
201+
</button>
202+
</div>
203+
);
204+
}

0 commit comments

Comments
 (0)