This repository has no description
1import React, { useState, useEffect, useCallback } from 'react';
2import { supabase } from '../../lib/supabase';
3import { Link } from 'react-router-dom';
4import './Leaderboard.css';
5
6// Function to truncate handles that are too long
7const truncateHandle = (handle, maxLength = 20) => {
8 if (!handle) return '';
9 if (handle.length <= maxLength) return handle;
10
11 // For handles with domains, try to preserve the beginning and domain part
12 if (handle.includes('.')) {
13 const parts = handle.split('.');
14 const domain = parts.slice(-2).join('.');
15 const username = parts.slice(0, -2).join('.');
16
17 // If username + domain is short enough, just return it
18 if (username.length + domain.length + 1 <= maxLength) {
19 return `${username}.${domain}`;
20 }
21
22 // Otherwise, truncate the username part
23 const availableLength = maxLength - domain.length - 4; // Account for ellipsis and dot
24 if (availableLength > 0) {
25 return `${username.substring(0, availableLength)}...${domain}`;
26 }
27 }
28
29 // For simple handles or fallback
30 return `${handle.substring(0, maxLength - 3)}...`;
31};
32
33const Leaderboard = () => {
34 const [users, setUsers] = useState([]);
35 const [runnerUps, setRunnerUps] = useState([]);
36 const [loading, setLoading] = useState(true);
37 const [error, setError] = useState(null);
38 const [scoreType, setScoreType] = useState('combined_score');
39
40 const scoreTypes = {
41 combined_score: 'Combined Score',
42 bluesky_score: 'Bluesky Score',
43 atproto_score: 'ATProto Score'
44 };
45
46 // Calculate protocol balance and determine which side has more activity
47 const calculateProtocolBalance = (bskyRecords, nonBskyRecords) => {
48 if (!bskyRecords && !nonBskyRecords) return { score: 50, leaning: 'neutral' };
49 const total = bskyRecords + nonBskyRecords;
50 if (total === 0) return { score: 50, leaning: 'neutral' };
51
52 // Calculate the percentage of bsky records
53 const bskyPercentage = (bskyRecords / total) * 100;
54
55 // Return both the percentage and which side it leans towards
56 return {
57 score: bskyPercentage,
58 leaning: bskyRecords > nonBskyRecords ? 'bsky' : 'atproto'
59 };
60 };
61
62 const getBalanceDescription = (bskyRecords, nonBskyRecords) => {
63 const { score, leaning } = calculateProtocolBalance(bskyRecords, nonBskyRecords);
64
65 if (score >= 45 && score <= 55) return 'Balanced usage';
66 if (leaning === 'bsky') {
67 if (score > 90) return 'Almost entirely Bluesky';
68 if (score > 75) return 'Heavily Bluesky';
69 return 'Leans Bluesky';
70 } else {
71 if (score < 10) return 'Almost entirely ATProto';
72 if (score < 25) return 'Heavily ATProto';
73 return 'Leans ATProto';
74 }
75 };
76
77 const fetchUsers = useCallback(async () => {
78 try {
79 setLoading(true);
80
81 console.log(`Fetching leaderboard data for scoreType: ${scoreType}`);
82
83 // Call the backend endpoint instead of directly querying Supabase
84 const response = await fetch(`https://api.cred.blue/api/leaderboard?scoreType=${scoreType}&limit=100`);
85
86 // Check for non-200 responses
87 if (!response.ok) {
88 let errorMessage;
89 try {
90 // Try to parse error JSON
91 const errorData = await response.json();
92 errorMessage = errorData.error || `Server error: ${response.status} ${response.statusText}`;
93 } catch (parseError) {
94 // If JSON parsing fails, use status text
95 errorMessage = `Server error: ${response.status} ${response.statusText}`;
96 }
97 throw new Error(errorMessage);
98 }
99
100 // Parse successful response
101 let data;
102 try {
103 data = await response.json();
104 } catch (parseError) {
105 console.error('Error parsing JSON response:', parseError);
106 throw new Error('Invalid response format from server');
107 }
108
109 // Debug logging
110 console.log(`Received ${data.topUsers?.length || 0} top users and ${data.runnerUps?.length || 0} runner ups`);
111
112 setUsers(data.topUsers || []);
113 setRunnerUps(data.runnerUps || []);
114
115 } catch (err) {
116 console.error('Error fetching leaderboard:', err);
117 setError(err.message);
118 } finally {
119 setLoading(false);
120 }
121 }, [scoreType]);
122
123 useEffect(() => {
124 fetchUsers();
125 }, [fetchUsers]);
126
127 const handleScoreTypeChange = (type) => {
128 setScoreType(type);
129 setUsers([]);
130 setRunnerUps([]);
131 };
132
133 const renderUserRow = (user, index, isRunnerUp = false) => (
134 <tr key={user.handle} className={isRunnerUp ? 'runner-up' : ''}>
135 <td className="rank-cell">#{index + 1}</td>
136 <td>
137 <a
138 href={`/${user.handle}`}
139 className="user-handle"
140 title={`@${user.handle}`} // Add title for hover to see full handle
141 >
142 @{truncateHandle(user.handle)}
143 </a>
144 </td>
145 <td className="score-cell">
146 {Math.round(user[scoreType] || 0)}
147 </td>
148 <td>
149 <div className="balance-indicator">
150 <div className="balance-track">
151 <div
152 className="balance-bar"
153 style={{
154 left: `${calculateProtocolBalance(user.total_bsky_records, user.total_non_bsky_records).score}%`
155 }}
156 ></div>
157 <div className="protocol-labels">
158 <span className="at-proto-label">ATProto</span>
159 <span className="bsky-label">Bluesky</span>
160 </div>
161 </div>
162 <span className="balance-description">
163 {getBalanceDescription(user.total_bsky_records, user.total_non_bsky_records)}
164 </span>
165 </div>
166 </td>
167 <td className="age-cell">
168 {Math.round(user.age_in_days)} days
169 </td>
170 <td>
171 <span className="activity-badge">
172 {user.activity_status || 'Unknown'}
173 </span>
174 </td>
175 </tr>
176 );
177
178 return (
179 <div className="leaderboard-container">
180 <div className="leaderboard-card">
181 <div className="leaderboard-header">
182 <h1>Leaderboard (Top 100)</h1>
183 <p className="leaderboard-description">
184 Discover the highest scoring accounts across Bluesky and the AT Protocol network that have been calculated so far. Scores are based on numerous factors across activity and protocol participation. If a username has never been searched on cred.blue, it won't appear here. <Link to="/methodology">Learn more about the scoring methodology.</Link>
185 </p>
186 </div>
187
188 <div className="score-type-filters">
189 {Object.entries(scoreTypes).map(([value, label]) => (
190 <button
191 key={value}
192 onClick={() => handleScoreTypeChange(value)}
193 className={`filter-button ${scoreType === value ? 'active' : ''}`}
194 >
195 {label}
196 </button>
197 ))}
198 </div>
199
200 {error && (
201 <div className="error-message">
202 Error loading leaderboard: {error}
203 </div>
204 )}
205
206 <div className="table-wrapper">
207 <div className="table-container">
208 <table className="leaderboard-table">
209 <thead>
210 <tr>
211 <th>Rank</th>
212 <th>Handle</th>
213 <th className="score-column">Score</th>
214 <th>Protocol Balance</th>
215 <th>Account Age</th>
216 <th>Activity Status</th>
217 </tr>
218 </thead>
219 <tbody>
220 {users.map((user, index) => renderUserRow(user, index))}
221 {runnerUps.map((user, index) => renderUserRow(user, index + 100, true))}
222 </tbody>
223 </table>
224 </div>
225 </div>
226
227 {loading && (
228 <div className="loading-container">
229 <div className="loading-spinner"></div>
230 </div>
231 )}
232 </div>
233 </div>
234 );
235};
236
237export default Leaderboard;