···
1
1
+
import { NextRequest, NextResponse } from 'next/server';
2
2
+
import { createClient } from '@supabase/supabase-js';
3
3
+
4
4
+
// Supabase client - using environment variables
5
5
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
6
6
+
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
7
7
+
8
8
+
export async function GET(request: NextRequest) {
9
9
+
try {
10
10
+
// If we have Supabase credentials, fetch stats
11
11
+
if (supabaseUrl && supabaseKey) {
12
12
+
const supabase = createClient(supabaseUrl, supabaseKey);
13
13
+
14
14
+
// 1. Get total flush count
15
15
+
const { count: totalCount, error: countError } = await supabase
16
16
+
.from('flushing_records')
17
17
+
.select('*', { count: 'exact', head: true });
18
18
+
19
19
+
if (countError) {
20
20
+
throw new Error(`Failed to get total count: ${countError.message}`);
21
21
+
}
22
22
+
23
23
+
// 2. Get daily flush counts for the chart
24
24
+
const { data: dailyData, error: dailyError } = await supabase
25
25
+
.from('flushing_records')
26
26
+
.select('created_at')
27
27
+
.order('created_at', { ascending: true });
28
28
+
29
29
+
if (dailyError) {
30
30
+
throw new Error(`Failed to get daily data: ${dailyError.message}`);
31
31
+
}
32
32
+
33
33
+
// Create a map of date -> count
34
34
+
const dailyCounts = new Map<string, number>();
35
35
+
36
36
+
// Process each entry to get daily counts
37
37
+
dailyData?.forEach(entry => {
38
38
+
const date = new Date(entry.created_at);
39
39
+
const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
40
40
+
41
41
+
if (dailyCounts.has(dateKey)) {
42
42
+
dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1);
43
43
+
} else {
44
44
+
dailyCounts.set(dateKey, 1);
45
45
+
}
46
46
+
});
47
47
+
48
48
+
// Convert to array sorted by date
49
49
+
const chartData = Array.from(dailyCounts.entries())
50
50
+
.map(([date, count]): {date: string, count: number} => ({ date, count }))
51
51
+
.sort((a, b) => a.date.localeCompare(b.date));
52
52
+
53
53
+
// Calculate flushes per day
54
54
+
let flushesPerDay = 0;
55
55
+
if (chartData.length > 0) {
56
56
+
// Calculate days between first and last flush
57
57
+
const firstDate = new Date(chartData[0].date);
58
58
+
const lastDate = new Date(chartData[chartData.length - 1].date);
59
59
+
const daysDiff = Math.max(1, Math.ceil((lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24)));
60
60
+
flushesPerDay = parseFloat((totalCount / daysDiff).toFixed(1));
61
61
+
}
62
62
+
63
63
+
// 3. Get top flushers (leaderboard)
64
64
+
const { data: leaderboardData, error: leaderboardError } = await supabase
65
65
+
.from('flushing_records')
66
66
+
.select('did, count')
67
67
+
.select('did')
68
68
+
.order('created_at', { ascending: false });
69
69
+
70
70
+
if (leaderboardError) {
71
71
+
throw new Error(`Failed to get leaderboard data: ${leaderboardError.message}`);
72
72
+
}
73
73
+
74
74
+
// Count flushes by DID
75
75
+
const didCounts = new Map<string, number>();
76
76
+
leaderboardData?.forEach(entry => {
77
77
+
didCounts.set(entry.did, (didCounts.get(entry.did) || 0) + 1);
78
78
+
});
79
79
+
80
80
+
// Convert to array and sort by count
81
81
+
const leaderboard = Array.from(didCounts.entries())
82
82
+
.map(([did, count]): {did: string, count: number} => ({ did, count }))
83
83
+
.sort((a, b) => b.count - a.count)
84
84
+
.slice(0, 10); // Get top 10
85
85
+
86
86
+
// Return the data
87
87
+
return NextResponse.json({
88
88
+
totalCount,
89
89
+
flushesPerDay,
90
90
+
chartData: chartData.slice(-30), // Last 30 days
91
91
+
leaderboard
92
92
+
});
93
93
+
} else {
94
94
+
// If no Supabase credentials, return mock data
95
95
+
return NextResponse.json({
96
96
+
totalCount: 42,
97
97
+
flushesPerDay: 3.5,
98
98
+
chartData: generateMockChartData(),
99
99
+
leaderboard: generateMockLeaderboard()
100
100
+
});
101
101
+
}
102
102
+
} catch (error: any) {
103
103
+
console.error('Error fetching stats:', error);
104
104
+
return NextResponse.json(
105
105
+
{ error: 'Failed to fetch stats', message: error.message },
106
106
+
{ status: 500 }
107
107
+
);
108
108
+
}
109
109
+
}
110
110
+
111
111
+
// Generate mock chart data
112
112
+
function generateMockChartData() {
113
113
+
const chartData = [];
114
114
+
const today = new Date();
115
115
+
116
116
+
for (let i = 29; i >= 0; i--) {
117
117
+
const date = new Date(today);
118
118
+
date.setDate(date.getDate() - i);
119
119
+
const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
120
120
+
121
121
+
// Random count between 1 and 5
122
122
+
const count = Math.floor(Math.random() * 5) + 1;
123
123
+
124
124
+
chartData.push({ date: dateString, count });
125
125
+
}
126
126
+
127
127
+
return chartData;
128
128
+
}
129
129
+
130
130
+
// Generate mock leaderboard
131
131
+
function generateMockLeaderboard() {
132
132
+
const mockDids = [
133
133
+
'did:plc:mock1',
134
134
+
'did:plc:mock2',
135
135
+
'did:plc:mock3',
136
136
+
'did:plc:mock4',
137
137
+
'did:plc:mock5',
138
138
+
'did:plc:mock6',
139
139
+
'did:plc:mock7',
140
140
+
'did:plc:mock8',
141
141
+
'did:plc:mock9',
142
142
+
'did:plc:mock10'
143
143
+
];
144
144
+
145
145
+
return mockDids.map((did, index) => ({
146
146
+
did,
147
147
+
count: 10 - index
148
148
+
}));
149
149
+
}
···
38
38
39
39
.loginForm h1 {
40
40
color: var(--primary-color);
41
41
-
margin-bottom: 1rem;
41
41
+
margin-bottom: 0.25rem;
42
42
+
}
43
43
+
44
44
+
.subtitle {
45
45
+
color: #666;
46
46
+
font-size: 1.1rem;
47
47
+
margin: 0 0 1rem 0;
48
48
+
font-style: italic;
42
49
}
43
50
44
51
.description {
···
135
135
) : (
136
136
<div className={styles.loginForm}>
137
137
<h1>Login with Bluesky</h1>
138
138
+
<p className={styles.subtitle}>using your AT Protocol account</p>
138
139
<p className={styles.description}>
139
140
Enter your Bluesky handle to continue. This works with any Bluesky account,
140
141
including those on custom PDS servers.
···
161
162
</button>
162
163
</div>
163
164
<p className={styles.helpText}>
164
164
-
Examples: alice.bsky.social, bob.com, or any other Bluesky handle
165
165
+
Examples: alice.bsky.social, bob.com, etc.
165
166
</p>
166
167
</form>
167
168
···
439
439
font-size: 0.9rem;
440
440
color: #666;
441
441
margin: 0;
442
442
+
display: flex;
443
443
+
flex-direction: column;
444
444
+
gap: 0.5rem;
445
445
+
}
446
446
+
447
447
+
.statsLink {
448
448
+
display: inline-block;
449
449
+
color: var(--primary-color);
450
450
+
font-weight: 500;
451
451
+
text-decoration: none;
452
452
+
transition: color 0.2s;
453
453
+
margin-top: 0.25rem;
454
454
+
}
455
455
+
456
456
+
.statsLink:hover {
457
457
+
text-decoration: underline;
458
458
+
color: var(--secondary-color);
442
459
}
443
460
444
461
.refreshButton {
···
357
357
<div className={styles.feedHeader}>
358
358
<div className={styles.feedHeaderLeft}>
359
359
<h2>Recent flushes</h2>
360
360
-
<p className={styles.feedSubheader}>Click on a username to see their custom flushing profile.</p>
360
360
+
<p className={styles.feedSubheader}>
361
361
+
Click on a username to see their custom flushing profile.
362
362
+
<Link href="/stats" className={styles.statsLink}>View Plumbing Stats 🪠</Link>
363
363
+
</p>
361
364
</div>
362
365
<button
363
366
onClick={() => fetchLatestEntries(true)}
···
1
1
+
'use client';
2
2
+
3
3
+
import { useState, useEffect } from 'react';
4
4
+
import Link from 'next/link';
5
5
+
import styles from './stats.module.css';
6
6
+
import { formatRelativeTime } from '@/lib/time-utils';
7
7
+
8
8
+
interface StatsData {
9
9
+
totalCount: number;
10
10
+
flushesPerDay: number;
11
11
+
chartData: { date: string; count: number }[];
12
12
+
leaderboard: { did: string; count: number; handle?: string }[];
13
13
+
}
14
14
+
15
15
+
export default function StatsPage() {
16
16
+
const [statsData, setStatsData] = useState<StatsData | null>(null);
17
17
+
const [loading, setLoading] = useState(true);
18
18
+
const [error, setError] = useState<string | null>(null);
19
19
+
20
20
+
useEffect(() => {
21
21
+
// Fetch stats data when the component mounts
22
22
+
fetchStatsData();
23
23
+
}, []);
24
24
+
25
25
+
// Function to fetch stats data
26
26
+
const fetchStatsData = async () => {
27
27
+
try {
28
28
+
setLoading(true);
29
29
+
setError(null);
30
30
+
31
31
+
const response = await fetch('/api/bluesky/stats', {
32
32
+
cache: 'no-store',
33
33
+
headers: {
34
34
+
'Cache-Control': 'no-cache',
35
35
+
'Pragma': 'no-cache'
36
36
+
}
37
37
+
});
38
38
+
39
39
+
if (!response.ok) {
40
40
+
throw new Error(`Failed to fetch stats: ${response.status}`);
41
41
+
}
42
42
+
43
43
+
const data = await response.json();
44
44
+
45
45
+
// Process leaderboard data - resolve handles when possible
46
46
+
const leaderboardWithHandles = await Promise.all(
47
47
+
data.leaderboard.map(async (item: { did: string; count: number }) => {
48
48
+
// Try to resolve the DID to a handle
49
49
+
try {
50
50
+
const handleResponse = await fetch(`https://plc.directory/${item.did}/data`);
51
51
+
if (handleResponse.ok) {
52
52
+
const didDoc = await handleResponse.json();
53
53
+
// Extract handle from alsoKnownAs
54
54
+
const handleUrl = didDoc.alsoKnownAs?.[0];
55
55
+
if (handleUrl && handleUrl.startsWith('at://')) {
56
56
+
const handle = handleUrl.substring(5); // Remove 'at://'
57
57
+
return { ...item, handle };
58
58
+
}
59
59
+
}
60
60
+
} catch (e) {
61
61
+
console.error(`Failed to resolve handle for DID ${item.did}`, e);
62
62
+
}
63
63
+
// Return original item if handle resolution fails
64
64
+
return item;
65
65
+
})
66
66
+
);
67
67
+
68
68
+
setStatsData({
69
69
+
...data,
70
70
+
leaderboard: leaderboardWithHandles
71
71
+
});
72
72
+
} catch (err: any) {
73
73
+
console.error('Error fetching stats:', err);
74
74
+
setError(err.message || 'Failed to load stats');
75
75
+
} finally {
76
76
+
setLoading(false);
77
77
+
}
78
78
+
};
79
79
+
80
80
+
return (
81
81
+
<div className={styles.container}>
82
82
+
<header className={styles.header}>
83
83
+
<h1>Plumbing Stats 🪠</h1>
84
84
+
<p className={styles.subtitle}>
85
85
+
Global statistics for the im.flushing network
86
86
+
</p>
87
87
+
</header>
88
88
+
89
89
+
<div className={styles.controls}>
90
90
+
<button
91
91
+
onClick={() => fetchStatsData()}
92
92
+
className={styles.refreshButton}
93
93
+
disabled={loading}
94
94
+
>
95
95
+
{loading ? 'Loading...' : 'Refresh Stats'}
96
96
+
</button>
97
97
+
<Link href="/" className={styles.homeLink}>
98
98
+
Back to Dashboard
99
99
+
</Link>
100
100
+
</div>
101
101
+
102
102
+
{error && (
103
103
+
<div className={styles.error}>
104
104
+
Error: {error}
105
105
+
</div>
106
106
+
)}
107
107
+
108
108
+
{loading ? (
109
109
+
<div className={styles.loadingContainer}>
110
110
+
<div className={styles.loader}></div>
111
111
+
<p>Loading stats...</p>
112
112
+
</div>
113
113
+
) : statsData ? (
114
114
+
<div className={styles.statsContent}>
115
115
+
{/* Overall Stats */}
116
116
+
<section className={styles.overallStats}>
117
117
+
<h2>Overall Flush Activity</h2>
118
118
+
<div className={styles.statsGrid}>
119
119
+
<div className={styles.statCard}>
120
120
+
<div className={styles.statValue}>{statsData.totalCount}</div>
121
121
+
<div className={styles.statLabel}>Total Flushes</div>
122
122
+
</div>
123
123
+
<div className={styles.statCard}>
124
124
+
<div className={styles.statValue}>{statsData.flushesPerDay}</div>
125
125
+
<div className={styles.statLabel}>Flushes Per Day</div>
126
126
+
</div>
127
127
+
</div>
128
128
+
</section>
129
129
+
130
130
+
{/* Activity Chart */}
131
131
+
<section className={styles.chartSection}>
132
132
+
<h2>Daily Activity</h2>
133
133
+
{statsData.chartData.length > 0 ? (
134
134
+
<>
135
135
+
<div className={styles.chartContainer}>
136
136
+
{statsData.chartData.map((dataPoint, index) => {
137
137
+
// Calculate height percentage (max of 100%)
138
138
+
const maxCount = Math.max(...statsData.chartData.map(d => d.count));
139
139
+
const heightPercent = Math.max(10, Math.min(100, (dataPoint.count / maxCount) * 100));
140
140
+
141
141
+
return (
142
142
+
<div
143
143
+
key={index}
144
144
+
className={styles.chartBar}
145
145
+
style={{ height: `${heightPercent}%` }}
146
146
+
title={`${dataPoint.date}: ${dataPoint.count} flushes`}
147
147
+
/>
148
148
+
);
149
149
+
})}
150
150
+
</div>
151
151
+
152
152
+
<div className={styles.chartLegend}>
153
153
+
<span className={styles.chartLegendItem}>
154
154
+
{statsData.chartData.length > 0 ? new Date(statsData.chartData[0].date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''}
155
155
+
</span>
156
156
+
<span className={styles.chartLegendItem}>
157
157
+
{statsData.chartData.length > 0 ? new Date(statsData.chartData[statsData.chartData.length - 1].date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''}
158
158
+
</span>
159
159
+
</div>
160
160
+
</>
161
161
+
) : (
162
162
+
<p className={styles.noDataMessage}>Not enough data to display activity chart</p>
163
163
+
)}
164
164
+
</section>
165
165
+
166
166
+
{/* Leaderboard */}
167
167
+
<section className={styles.leaderboardSection}>
168
168
+
<h2>Top Flushers</h2>
169
169
+
{statsData.leaderboard.length > 0 ? (
170
170
+
<div className={styles.leaderboard}>
171
171
+
<div className={styles.leaderboardHeader}>
172
172
+
<span className={styles.rank}>Rank</span>
173
173
+
<span className={styles.user}>User</span>
174
174
+
<span className={styles.count}>Flushes</span>
175
175
+
</div>
176
176
+
{statsData.leaderboard.map((item, index) => (
177
177
+
<div key={index} className={`${styles.leaderboardItem} ${index === 0 ? styles.topRank : ''}`}>
178
178
+
<span className={styles.rank}>#{index + 1}</span>
179
179
+
<span className={styles.user}>
180
180
+
{item.handle ? (
181
181
+
<Link href={`/profile/${item.handle}`}>
182
182
+
@{item.handle}
183
183
+
</Link>
184
184
+
) : (
185
185
+
<span className={styles.unknownUser}>
186
186
+
{item.did.substring(0, 10)}...
187
187
+
</span>
188
188
+
)}
189
189
+
</span>
190
190
+
<span className={styles.count}>{item.count}</span>
191
191
+
</div>
192
192
+
))}
193
193
+
</div>
194
194
+
) : (
195
195
+
<p className={styles.noDataMessage}>No leaderboard data available</p>
196
196
+
)}
197
197
+
</section>
198
198
+
199
199
+
<div className={styles.shareSection}>
200
200
+
<button
201
201
+
className={styles.shareButton}
202
202
+
onClick={() => {
203
203
+
// Generate share text
204
204
+
const statsText = `There have been ${statsData.totalCount} flushes on @flushing.im! That's ${statsData.flushesPerDay} flushes per day. Check out the stats and leaderboard: https://flushing.im/stats`;
205
205
+
window.open(`https://bsky.app/intent/compose?text=${encodeURIComponent(statsText)}`, '_blank');
206
206
+
}}
207
207
+
>
208
208
+
Share These Stats
209
209
+
</button>
210
210
+
</div>
211
211
+
</div>
212
212
+
) : (
213
213
+
<div className={styles.emptyState}>
214
214
+
<p>No stats data available</p>
215
215
+
</div>
216
216
+
)}
217
217
+
</div>
218
218
+
);
219
219
+
}
···
1
1
+
.container {
2
2
+
max-width: 800px;
3
3
+
margin: 0 auto;
4
4
+
padding: 2rem 1rem;
5
5
+
}
6
6
+
7
7
+
.header {
8
8
+
text-align: center;
9
9
+
margin-bottom: 2rem;
10
10
+
}
11
11
+
12
12
+
.header h1 {
13
13
+
font-size: 2.5rem;
14
14
+
margin-bottom: 0.5rem;
15
15
+
color: var(--primary-color);
16
16
+
}
17
17
+
18
18
+
.subtitle {
19
19
+
color: #666;
20
20
+
font-size: 1.2rem;
21
21
+
}
22
22
+
23
23
+
.controls {
24
24
+
display: flex;
25
25
+
justify-content: center;
26
26
+
gap: 1rem;
27
27
+
margin-bottom: 2rem;
28
28
+
}
29
29
+
30
30
+
.refreshButton {
31
31
+
background-color: var(--primary-color);
32
32
+
color: white;
33
33
+
border: none;
34
34
+
border-radius: 4px;
35
35
+
padding: 0.5rem 1rem;
36
36
+
font-size: 1rem;
37
37
+
cursor: pointer;
38
38
+
transition: background-color 0.2s;
39
39
+
}
40
40
+
41
41
+
.refreshButton:hover:not(:disabled) {
42
42
+
background-color: var(--secondary-color);
43
43
+
}
44
44
+
45
45
+
.refreshButton:disabled {
46
46
+
background-color: #ccc;
47
47
+
cursor: not-allowed;
48
48
+
}
49
49
+
50
50
+
.homeLink {
51
51
+
display: inline-block;
52
52
+
color: var(--primary-color);
53
53
+
text-decoration: none;
54
54
+
border: 1px solid var(--primary-color);
55
55
+
border-radius: 4px;
56
56
+
padding: 0.5rem 1rem;
57
57
+
font-size: 1rem;
58
58
+
transition: all 0.2s;
59
59
+
}
60
60
+
61
61
+
.homeLink:hover {
62
62
+
background-color: rgba(91, 173, 240, 0.1);
63
63
+
}
64
64
+
65
65
+
.loadingContainer {
66
66
+
display: flex;
67
67
+
flex-direction: column;
68
68
+
align-items: center;
69
69
+
justify-content: center;
70
70
+
padding: 3rem;
71
71
+
text-align: center;
72
72
+
}
73
73
+
74
74
+
.loader {
75
75
+
border: 4px solid #f3f3f3;
76
76
+
border-top: 4px solid var(--primary-color);
77
77
+
border-radius: 50%;
78
78
+
width: 40px;
79
79
+
height: 40px;
80
80
+
animation: spin 1s linear infinite;
81
81
+
margin-bottom: 1rem;
82
82
+
}
83
83
+
84
84
+
@keyframes spin {
85
85
+
0% { transform: rotate(0deg); }
86
86
+
100% { transform: rotate(360deg); }
87
87
+
}
88
88
+
89
89
+
.error {
90
90
+
background-color: #ffebee;
91
91
+
color: #c62828;
92
92
+
padding: 1rem;
93
93
+
border-radius: 4px;
94
94
+
margin-bottom: 1rem;
95
95
+
}
96
96
+
97
97
+
.emptyState {
98
98
+
text-align: center;
99
99
+
padding: 3rem;
100
100
+
color: #666;
101
101
+
}
102
102
+
103
103
+
/* Stats Content Sections */
104
104
+
.statsContent {
105
105
+
display: flex;
106
106
+
flex-direction: column;
107
107
+
gap: 2rem;
108
108
+
}
109
109
+
110
110
+
.overallStats, .chartSection, .leaderboardSection {
111
111
+
background: white;
112
112
+
border-radius: 8px;
113
113
+
padding: 1.5rem;
114
114
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
115
115
+
}
116
116
+
117
117
+
.overallStats h2, .chartSection h2, .leaderboardSection h2 {
118
118
+
color: var(--primary-color);
119
119
+
margin-bottom: 1.5rem;
120
120
+
font-size: 1.5rem;
121
121
+
text-align: center;
122
122
+
}
123
123
+
124
124
+
/* Stats Grid */
125
125
+
.statsGrid {
126
126
+
display: grid;
127
127
+
grid-template-columns: repeat(2, 1fr);
128
128
+
gap: 1.5rem;
129
129
+
}
130
130
+
131
131
+
.statCard {
132
132
+
background: #f8f9fa;
133
133
+
padding: 1.5rem;
134
134
+
border-radius: 8px;
135
135
+
text-align: center;
136
136
+
transition: transform 0.2s;
137
137
+
}
138
138
+
139
139
+
.statCard:hover {
140
140
+
transform: translateY(-5px);
141
141
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
142
142
+
}
143
143
+
144
144
+
.statValue {
145
145
+
font-size: 2.5rem;
146
146
+
font-weight: bold;
147
147
+
color: var(--primary-color);
148
148
+
margin-bottom: 0.5rem;
149
149
+
}
150
150
+
151
151
+
.statLabel {
152
152
+
color: #666;
153
153
+
font-size: 1.1rem;
154
154
+
}
155
155
+
156
156
+
/* Chart Styles */
157
157
+
.chartContainer {
158
158
+
height: 200px;
159
159
+
display: flex;
160
160
+
align-items: flex-end;
161
161
+
gap: 2px;
162
162
+
margin-bottom: 1rem;
163
163
+
overflow-x: auto;
164
164
+
padding-bottom: 0.5rem;
165
165
+
}
166
166
+
167
167
+
.chartBar {
168
168
+
flex: 1;
169
169
+
min-width: 10px;
170
170
+
border-radius: 2px 2px 0 0;
171
171
+
background-color: var(--primary-color);
172
172
+
transition: height 0.5s ease;
173
173
+
}
174
174
+
175
175
+
.chartLegend {
176
176
+
display: flex;
177
177
+
justify-content: space-between;
178
178
+
color: #666;
179
179
+
font-size: 0.9rem;
180
180
+
}
181
181
+
182
182
+
.noDataMessage {
183
183
+
text-align: center;
184
184
+
color: #666;
185
185
+
font-style: italic;
186
186
+
padding: 2rem 0;
187
187
+
}
188
188
+
189
189
+
/* Leaderboard Styles */
190
190
+
.leaderboard {
191
191
+
border: 1px solid #eee;
192
192
+
border-radius: 8px;
193
193
+
overflow: hidden;
194
194
+
}
195
195
+
196
196
+
.leaderboardHeader {
197
197
+
display: grid;
198
198
+
grid-template-columns: 80px 1fr 100px;
199
199
+
padding: 1rem;
200
200
+
background: #f5f5f5;
201
201
+
font-weight: bold;
202
202
+
color: #333;
203
203
+
}
204
204
+
205
205
+
.leaderboardItem {
206
206
+
display: grid;
207
207
+
grid-template-columns: 80px 1fr 100px;
208
208
+
padding: 1rem;
209
209
+
border-top: 1px solid #eee;
210
210
+
transition: background-color 0.2s;
211
211
+
}
212
212
+
213
213
+
.leaderboardItem:hover {
214
214
+
background-color: #f9f9f9;
215
215
+
}
216
216
+
217
217
+
.topRank {
218
218
+
background-color: #fff8e1;
219
219
+
}
220
220
+
221
221
+
.rank {
222
222
+
font-weight: bold;
223
223
+
color: #666;
224
224
+
}
225
225
+
226
226
+
.user a {
227
227
+
color: var(--primary-color);
228
228
+
text-decoration: none;
229
229
+
font-weight: 500;
230
230
+
}
231
231
+
232
232
+
.user a:hover {
233
233
+
text-decoration: underline;
234
234
+
}
235
235
+
236
236
+
.unknownUser {
237
237
+
color: #999;
238
238
+
font-style: italic;
239
239
+
}
240
240
+
241
241
+
.count {
242
242
+
font-weight: 500;
243
243
+
text-align: right;
244
244
+
}
245
245
+
246
246
+
/* Share Button */
247
247
+
.shareSection {
248
248
+
display: flex;
249
249
+
justify-content: center;
250
250
+
margin-top: 1rem;
251
251
+
}
252
252
+
253
253
+
.shareButton {
254
254
+
background-color: var(--primary-color);
255
255
+
color: white;
256
256
+
border: none;
257
257
+
border-radius: 4px;
258
258
+
padding: 0.75rem 1.5rem;
259
259
+
font-size: 1.1rem;
260
260
+
cursor: pointer;
261
261
+
transition: all 0.2s;
262
262
+
}
263
263
+
264
264
+
.shareButton:hover {
265
265
+
background-color: var(--secondary-color);
266
266
+
transform: translateY(-2px);
267
267
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
268
268
+
}
269
269
+
270
270
+
/* Responsive Adjustments */
271
271
+
@media (max-width: 600px) {
272
272
+
.container {
273
273
+
padding: 1rem;
274
274
+
}
275
275
+
276
276
+
.header h1 {
277
277
+
font-size: 1.8rem;
278
278
+
}
279
279
+
280
280
+
.subtitle {
281
281
+
font-size: 1rem;
282
282
+
}
283
283
+
284
284
+
.statsGrid {
285
285
+
grid-template-columns: 1fr;
286
286
+
gap: 1rem;
287
287
+
}
288
288
+
289
289
+
.statCard {
290
290
+
padding: 1rem;
291
291
+
}
292
292
+
293
293
+
.statValue {
294
294
+
font-size: 2rem;
295
295
+
}
296
296
+
297
297
+
.chartContainer {
298
298
+
height: 150px;
299
299
+
}
300
300
+
301
301
+
.leaderboardHeader, .leaderboardItem {
302
302
+
grid-template-columns: 60px 1fr 80px;
303
303
+
padding: 0.75rem;
304
304
+
font-size: 0.9rem;
305
305
+
}
306
306
+
}