Skip to content

Commit 5f893fb

Browse files
committed
feat: Added leaderboard page, TODO: backend and aesthetics
1 parent 0ba0ea5 commit 5f893fb

File tree

9 files changed

+504
-35
lines changed

9 files changed

+504
-35
lines changed

backend/src/api/handlers/notes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ async fn generate_preview_image(
167167
}
168168

169169
// Integration into your upload_note function
170+
// TODO: Add semester and year fields
170171
pub async fn upload_note(
171172
State(state): State<RouterState>,
172173
Extension(user): Extension<User>,

backend/src/db/handlers/notes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub async fn update_note_preview_status(
2323
Ok(())
2424
}
2525

26+
// TODO: Handle Year and Semester fields in the future, Add migrations removing reputation
2627
pub async fn create_note(
2728
db_wrapper: &DBPoolWrapper,
2829
new_note: CreateNote,

backend/src/db/handlers/users.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::db::db::DBPoolWrapper;
22
use crate::db::models::User;
3-
use serde::Deserialize;
3+
use serde::{Deserialize, Serialize};
4+
use sqlx::FromRow;
5+
use uuid::Uuid;
46

57
#[derive(Deserialize, Clone)]
68
pub struct GoogleUserInfo {
@@ -10,6 +12,18 @@ pub struct GoogleUserInfo {
1012
pub picture: String,
1113
}
1214

15+
#[derive(Debug, Serialize)]
16+
pub struct LeaderboardEntry {
17+
pub id: Uuid,
18+
pub full_name: String,
19+
pub picture: String,
20+
pub reputation: f64,
21+
pub total_notes: i64,
22+
pub total_upvotes: i64,
23+
pub total_downloads: i64,
24+
pub rank: i64,
25+
}
26+
1327
pub async fn find_user_by_google_id(
1428
db_wrapper: &DBPoolWrapper,
1529
google_id: &str,
@@ -46,3 +60,149 @@ pub async fn find_or_create_user(
4660

4761
Ok(new_user)
4862
}
63+
64+
pub async fn get_leaderboard(
65+
db_wrapper: &DBPoolWrapper,
66+
limit: i64,
67+
) -> Result<Vec<LeaderboardEntry>, sqlx::Error> {
68+
let rows = sqlx::query!(
69+
r#"
70+
WITH user_stats AS (
71+
SELECT
72+
u.id,
73+
u.full_name,
74+
u.picture,
75+
COUNT(DISTINCT n.id) as total_notes,
76+
COUNT(DISTINCT CASE WHEN v.is_upvote = true THEN v.id END) as total_upvotes,
77+
COALESCE(SUM(n.downloads), 0) as total_downloads
78+
FROM users u
79+
LEFT JOIN notes n ON u.id = n.uploader_user_id
80+
LEFT JOIN votes v ON n.id = v.note_id
81+
GROUP BY u.id, u.full_name, u.picture
82+
),
83+
user_reputation AS (
84+
SELECT
85+
id,
86+
full_name,
87+
picture,
88+
total_notes,
89+
total_upvotes,
90+
total_downloads,
91+
CASE
92+
WHEN total_notes > 0 THEN
93+
(total_upvotes::FLOAT / total_notes::FLOAT) *
94+
(total_notes + total_upvotes + total_downloads)
95+
ELSE 0
96+
END as reputation
97+
FROM user_stats
98+
)
99+
SELECT
100+
id,
101+
full_name,
102+
picture,
103+
reputation,
104+
total_notes,
105+
total_upvotes,
106+
total_downloads,
107+
RANK() OVER (ORDER BY reputation DESC, total_notes DESC) as rank
108+
FROM user_reputation
109+
ORDER BY rank
110+
LIMIT $1
111+
"#,
112+
limit
113+
)
114+
.fetch_all(db_wrapper.pool())
115+
.await?;
116+
117+
let leaderboard = rows
118+
.into_iter()
119+
.map(|row| LeaderboardEntry {
120+
id: row.id,
121+
full_name: row.full_name,
122+
picture: row.picture,
123+
reputation: row.reputation.unwrap_or(0.0),
124+
total_notes: row.total_notes.unwrap_or(0),
125+
total_upvotes: row.total_upvotes.unwrap_or(0),
126+
total_downloads: row.total_downloads.unwrap_or(0),
127+
rank: row.rank.unwrap_or(0),
128+
})
129+
.collect();
130+
131+
Ok(leaderboard)
132+
}
133+
134+
pub async fn get_user_leaderboard_position(
135+
db_wrapper: &DBPoolWrapper,
136+
user_id: Uuid,
137+
) -> Result<Option<LeaderboardEntry>, sqlx::Error> {
138+
let row = sqlx::query!(
139+
r#"
140+
WITH user_stats AS (
141+
SELECT
142+
u.id,
143+
u.full_name,
144+
u.picture,
145+
COUNT(DISTINCT n.id) as total_notes,
146+
COUNT(DISTINCT CASE WHEN v.is_upvote = true THEN v.id END) as total_upvotes,
147+
COALESCE(SUM(n.downloads), 0) as total_downloads
148+
FROM users u
149+
LEFT JOIN notes n ON u.id = n.uploader_user_id
150+
LEFT JOIN votes v ON n.id = v.note_id
151+
GROUP BY u.id, u.full_name, u.picture
152+
),
153+
user_reputation AS (
154+
SELECT
155+
id,
156+
full_name,
157+
picture,
158+
total_notes,
159+
total_upvotes,
160+
total_downloads,
161+
CASE
162+
WHEN total_notes > 0 THEN
163+
(total_upvotes::FLOAT / total_notes::FLOAT) *
164+
(total_notes + total_upvotes + total_downloads)
165+
ELSE 0
166+
END as reputation
167+
FROM user_stats
168+
),
169+
ranked_users AS (
170+
SELECT
171+
id,
172+
full_name,
173+
picture,
174+
reputation,
175+
total_notes,
176+
total_upvotes,
177+
total_downloads,
178+
RANK() OVER (ORDER BY reputation DESC, total_notes DESC) as rank
179+
FROM user_reputation
180+
)
181+
SELECT
182+
id,
183+
full_name,
184+
picture,
185+
reputation,
186+
total_notes,
187+
total_upvotes,
188+
total_downloads,
189+
rank
190+
FROM ranked_users
191+
WHERE id = $1
192+
"#,
193+
user_id
194+
)
195+
.fetch_optional(db_wrapper.pool())
196+
.await?;
197+
198+
Ok(row.map(|r| LeaderboardEntry {
199+
id: r.id,
200+
full_name: r.full_name,
201+
picture: r.picture,
202+
reputation: r.reputation.unwrap_or(0.0),
203+
total_notes: r.total_notes.unwrap_or(0),
204+
total_upvotes: r.total_upvotes.unwrap_or(0),
205+
total_downloads: r.total_downloads.unwrap_or(0),
206+
rank: r.rank.unwrap_or(0),
207+
}))
208+
}

frontend/package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"lucide-react": "^0.539.0",
1717
"react": "^19.1.1",
1818
"react-dom": "^19.1.1",
19-
"react-router-dom": "^7.8.0"
19+
"react-router-dom": "^7.9.4"
2020
},
2121
"devDependencies": {
2222
"@eslint/js": "^9.33.0",

frontend/src/App.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// App.tsx
22
import React, { useState, useEffect, useCallback } from 'react';
3+
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
34
import { AuthProvider } from './contexts/AuthContext';
45
import Header from './components/Header';
56
import SearchBar from './components/SearchBar';
67
import CourseGrid from './components/CourseGrid';
8+
import Leaderboard from './components/Leaderboard';
79
import { notesApi } from './api/notesApi';
810
import type { ResponseNote } from './types';
911

10-
const AppContent: React.FC = () => {
12+
const HomePage: React.FC = () => {
1113
const [searchQuery, setSearchQuery] = useState('');
1214
const [notes, setNotes] = useState<ResponseNote[]>([]);
1315
const [loading, setLoading] = useState(true);
@@ -57,33 +59,45 @@ const AppContent: React.FC = () => {
5759
};
5860

5961
return (
60-
<div className="min-h-screen bg-background flex flex-col">
61-
<AuthProvider onSignIn={loadNotes}>
62-
<Header onNoteUploaded={handleNoteUploaded} />
62+
<>
63+
<Header onNoteUploaded={handleNoteUploaded} />
6364

64-
<main className="flex-1 w-full px-6 sm:px-8 lg:px-12 xl:px-16 py-8">
65-
<div className="mb-8 max-w-3xl mx-auto">
66-
<SearchBar searchQuery={searchQuery} onSearchChange={handleSearchChange} />
67-
</div>
65+
<main className="flex-1 w-full px-6 sm:px-8 lg:px-12 xl:px-16 py-8">
66+
<div className="mb-8 max-w-3xl mx-auto">
67+
<SearchBar searchQuery={searchQuery} onSearchChange={handleSearchChange} />
68+
</div>
6869

69-
{error && (
70-
<div className="mb-6 p-4 bg-red-900 border border-red-700 rounded-lg text-red-300 max-w-3xl mx-auto">
71-
{error}
72-
</div>
73-
)}
70+
{error && (
71+
<div className="mb-6 p-4 bg-red-900 border border-red-700 rounded-lg text-red-300 max-w-3xl mx-auto">
72+
{error}
73+
</div>
74+
)}
7475

75-
{loading ? (
76-
<div className="flex justify-center items-center py-12">
77-
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
78-
<span className="ml-3 text-text-muted">Loading notes...</span>
79-
</div>
80-
) : (
81-
<CourseGrid notes={notes} />
82-
)}
83-
</main>
76+
{loading ? (
77+
<div className="flex justify-center items-center py-12">
78+
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
79+
<span className="ml-3 text-text-muted">Loading notes...</span>
80+
</div>
81+
) : (
82+
<CourseGrid notes={notes} />
83+
)}
84+
</main>
85+
</>
86+
);
87+
};
8488

89+
const AppContent: React.FC = () => {
90+
return (
91+
<Router>
92+
<AuthProvider>
93+
<div className="min-h-screen bg-background flex flex-col">
94+
<Routes>
95+
<Route path="/" element={<HomePage />} />
96+
<Route path="/leaderboard" element={<Leaderboard />} />
97+
</Routes>
98+
</div>
8599
</AuthProvider>
86-
</div>
100+
</Router>
87101
);
88102
};
89103

frontend/src/api/userApi.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { authenticatedFetch } from "./authApi.ts";
2+
3+
export interface LeaderboardEntry {
4+
id: string;
5+
full_name: string;
6+
picture: string;
7+
reputation: number;
8+
total_notes: number;
9+
total_upvotes: number;
10+
total_downloads: number;
11+
rank: number;
12+
}
13+
14+
class UserAPI {
15+
private async fetchWithErrorHandling(url: string, options?: RequestInit): Promise<any> {
16+
try {
17+
const response = await authenticatedFetch(url, options);
18+
if (!response.ok) {
19+
throw new Error(`HTTP error! status: ${response.status}`);
20+
}
21+
return await response.json();
22+
} catch (error) {
23+
console.error('API request failed:', error);
24+
throw error;
25+
}
26+
}
27+
28+
// GET /api/users/leaderboard?limit=20
29+
async getLeaderboard(limit: number = 20): Promise<LeaderboardEntry[]> {
30+
const url = `/api/users/leaderboard?limit=${limit}`;
31+
return this.fetchWithErrorHandling(url);
32+
}
33+
34+
// GET /api/users/:user_id/leaderboard-position
35+
async getUserLeaderboardPosition(userId: string): Promise<LeaderboardEntry | null> {
36+
const url = `/api/users/${userId}/leaderboard-position`;
37+
try {
38+
return await this.fetchWithErrorHandling(url);
39+
} catch (error) {
40+
console.error('Failed to get user leaderboard position:', error);
41+
return null;
42+
}
43+
}
44+
}
45+
46+
export const userApi = new UserAPI();

0 commit comments

Comments
 (0)