add leaderboard
This commit is contained in:
parent
c534fc6fb1
commit
0ca36b69eb
7 changed files with 902 additions and 1 deletions
34
src/components/dashboard/LeaderboardSection.astro
Normal file
34
src/components/dashboard/LeaderboardSection.astro
Normal file
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
import LeaderboardTable from "./LeaderboardSection/LeaderboardTable";
|
||||
import LeaderboardStats from "./LeaderboardSection/LeaderboardStats";
|
||||
---
|
||||
|
||||
<div class="grid gap-6">
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-6 h-6 text-[#f6b93b]"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 00-.584.859 6.753 6.753 0 006.138 5.6 6.73 6.73 0 002.743 1.346A6.707 6.707 0 019.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 00-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 00.75-.75 2.25 2.25 0 00-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 01-1.112-3.173 6.73 6.73 0 002.743-1.347 6.753 6.753 0 006.139-5.6.75.75 0 00-.585-.858 47.077 47.077 0 00-3.07-.543V2.62a.75.75 0 00-.658-.744 49.22 49.22 0 00-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 00-.657.744zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 013.16 5.337a45.6 45.6 0 012.006-.343v.256zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 01-2.863 3.207 6.72 6.72 0 00.857-3.294z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Leaderboard
|
||||
</h2>
|
||||
<div class="divider mt-0 mb-4"></div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="mb-6">
|
||||
<LeaderboardStats client:load />
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard table -->
|
||||
<LeaderboardTable client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
171
src/components/dashboard/LeaderboardSection/LeaderboardStats.tsx
Normal file
171
src/components/dashboard/LeaderboardSection/LeaderboardStats.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
|
||||
interface LeaderboardStats {
|
||||
totalUsers: number;
|
||||
totalPoints: number;
|
||||
topScore: number;
|
||||
yourPoints: number;
|
||||
yourRank: number | null;
|
||||
}
|
||||
|
||||
export default function LeaderboardStats() {
|
||||
const [stats, setStats] = useState<LeaderboardStats>({
|
||||
totalUsers: 0,
|
||||
totalPoints: 0,
|
||||
topScore: 0,
|
||||
yourPoints: 0,
|
||||
yourRank: null
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Set the current user ID once on component mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Use the Authentication class directly
|
||||
const isLoggedIn = auth.isAuthenticated();
|
||||
setIsAuthenticated(isLoggedIn);
|
||||
|
||||
if (isLoggedIn) {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user && user.id) {
|
||||
setCurrentUserId(user.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking authentication:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Get all users without sorting - we'll sort on client side
|
||||
const response = await get.getList('limitedUser', 1, 500, '', '', {
|
||||
fields: ['id', 'name', 'points']
|
||||
});
|
||||
|
||||
// Filter out users with no points for the leaderboard stats
|
||||
const leaderboardUsers = response.items
|
||||
.filter((user: any) =>
|
||||
user.points !== undefined &&
|
||||
user.points !== null &&
|
||||
user.points > 0
|
||||
)
|
||||
// Sort by points descending
|
||||
.sort((a: any, b: any) => b.points - a.points);
|
||||
|
||||
const totalUsers = leaderboardUsers.length;
|
||||
const totalPoints = leaderboardUsers.reduce((sum: number, user: any) => sum + (user.points || 0), 0);
|
||||
const topScore = leaderboardUsers.length > 0 ? leaderboardUsers[0].points : 0;
|
||||
|
||||
// Find current user's points and rank - BUT don't filter by points > 0 for the current user
|
||||
let yourPoints = 0;
|
||||
let yourRank = null;
|
||||
|
||||
if (isAuthenticated && currentUserId) {
|
||||
// Look for the current user in ALL users, not just those with points > 0
|
||||
const currentUser = response.items.find((user: any) => user.id === currentUserId);
|
||||
|
||||
if (currentUser) {
|
||||
yourPoints = currentUser.points || 0;
|
||||
|
||||
// Only calculate rank if user has points
|
||||
if (yourPoints > 0) {
|
||||
// Find user position in the sorted array
|
||||
for (let i = 0; i < leaderboardUsers.length; i++) {
|
||||
if (leaderboardUsers[i].id === currentUserId) {
|
||||
yourRank = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalUsers,
|
||||
totalPoints,
|
||||
topScore,
|
||||
yourPoints,
|
||||
yourRank
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching leaderboard stats:', err);
|
||||
// Set fallback stats
|
||||
setStats({
|
||||
totalUsers: 0,
|
||||
totalPoints: 0,
|
||||
topScore: 0,
|
||||
yourPoints: 0,
|
||||
yourRank: null
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [isAuthenticated, currentUserId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-24 bg-gray-100/50 dark:bg-gray-800/50 animate-pulse rounded-xl">
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-2 mt-4 mx-4"></div>
|
||||
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mx-4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Members</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalUsers}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">In the leaderboard</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Points</div>
|
||||
<div className="mt-2 text-3xl font-bold text-gray-800 dark:text-white">{stats.totalPoints}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Earned by all members</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Top Score</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">{stats.topScore}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Highest individual points</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white/90 dark:bg-gray-800/90 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-gray-300">Your Score</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{isAuthenticated ? stats.yourPoints : '-'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{isAuthenticated
|
||||
? (stats.yourRank ? `Ranked #${stats.yourRank}` : 'Not ranked yet')
|
||||
: 'Log in to see your rank'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
334
src/components/dashboard/LeaderboardSection/LeaderboardTable.tsx
Normal file
334
src/components/dashboard/LeaderboardSection/LeaderboardTable.tsx
Normal file
|
@ -0,0 +1,334 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import type { User } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
interface LeaderboardUser {
|
||||
id: string;
|
||||
name: string;
|
||||
points: number;
|
||||
avatar?: string;
|
||||
major?: string;
|
||||
}
|
||||
|
||||
// Trophy icon SVG for the rankings
|
||||
const TrophyIcon = ({ className }: { className: string }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path fillRule="evenodd" d="M5.166 2.621v.858c-1.035.148-2.059.33-3.071.543a.75.75 0 00-.584.859 6.753 6.753 0 006.138 5.6 6.73 6.73 0 002.743 1.346A6.707 6.707 0 019.279 15H8.54c-1.036 0-1.875.84-1.875 1.875V19.5h-.75a2.25 2.25 0 00-2.25 2.25c0 .414.336.75.75.75h15a.75.75 0 00.75-.75 2.25 2.25 0 00-2.25-2.25h-.75v-2.625c0-1.036-.84-1.875-1.875-1.875h-.739a6.706 6.706 0 01-1.112-3.173 6.73 6.73 0 002.743-1.347 6.753 6.753 0 006.139-5.6.75.75 0 00-.585-.858 47.077 47.077 0 00-3.07-.543V2.62a.75.75 0 00-.658-.744 49.22 49.22 0 00-6.093-.377c-2.063 0-4.096.128-6.093.377a.75.75 0 00-.657.744zm0 2.629c0 1.196.312 2.32.857 3.294A5.266 5.266 0 013.16 5.337a45.6 45.6 0 012.006-.343v.256zm13.5 0v-.256c.674.1 1.343.214 2.006.343a5.265 5.265 0 01-2.863 3.207 6.72 6.72 0 00.857-3.294z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function LeaderboardTable() {
|
||||
const [users, setUsers] = useState<LeaderboardUser[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<LeaderboardUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUserRank, setCurrentUserRank] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const usersPerPage = 10;
|
||||
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Set the current user ID once on component mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Use the Authentication class directly
|
||||
const isLoggedIn = auth.isAuthenticated();
|
||||
setIsAuthenticated(isLoggedIn);
|
||||
|
||||
if (isLoggedIn) {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user && user.id) {
|
||||
setCurrentUserId(user.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking authentication:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLeaderboard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch users without sorting - we'll sort on client side
|
||||
const response = await get.getList('limitedUser', 1, 100, '', '', {
|
||||
fields: ['id', 'name', 'points', 'avatar', 'major']
|
||||
});
|
||||
|
||||
// First get the current user separately so we can include them even if they have 0 points
|
||||
let currentUserData = null;
|
||||
if (isAuthenticated && currentUserId) {
|
||||
currentUserData = response.items.find((user: Partial<User>) => user.id === currentUserId);
|
||||
}
|
||||
|
||||
// Filter and map to our leaderboard user format, and sort client-side
|
||||
let leaderboardUsers = response.items
|
||||
.filter((user: Partial<User>) => user.points !== undefined && user.points !== null && user.points > 0)
|
||||
.sort((a: Partial<User>, b: Partial<User>) => (b.points || 0) - (a.points || 0))
|
||||
.map((user: Partial<User>, index: number) => {
|
||||
// Check if this is the current user
|
||||
if (isAuthenticated && user.id === currentUserId) {
|
||||
setCurrentUserRank(index + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id || '',
|
||||
name: user.name || 'Anonymous User',
|
||||
points: user.points || 0,
|
||||
avatar: user.avatar,
|
||||
major: user.major
|
||||
};
|
||||
});
|
||||
|
||||
// Include current user even if they have 0 points,
|
||||
// but don't include in ranking if they have no points
|
||||
if (isAuthenticated && currentUserData &&
|
||||
!leaderboardUsers.some(user => user.id === currentUserId)) {
|
||||
// User isn't already in the list (has 0 points)
|
||||
leaderboardUsers.push({
|
||||
id: currentUserData.id || '',
|
||||
name: currentUserData.name || 'Anonymous User',
|
||||
points: currentUserData.points || 0,
|
||||
avatar: currentUserData.avatar,
|
||||
major: currentUserData.major
|
||||
});
|
||||
}
|
||||
|
||||
setUsers(leaderboardUsers);
|
||||
setFilteredUsers(leaderboardUsers);
|
||||
} catch (err) {
|
||||
console.error('Error fetching leaderboard:', err);
|
||||
setError('Failed to load leaderboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLeaderboard();
|
||||
}, [isAuthenticated, currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === '') {
|
||||
setFilteredUsers(users);
|
||||
setCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = users.filter(user =>
|
||||
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(user.major && user.major.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, users]);
|
||||
|
||||
// Get current users for pagination
|
||||
const indexOfLastUser = currentPage * usersPerPage;
|
||||
const indexOfFirstUser = indexOfLastUser - usersPerPage;
|
||||
const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
|
||||
const totalPages = Math.ceil(filteredUsers.length / usersPerPage);
|
||||
|
||||
const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<div className="w-10 h-10 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-600 dark:text-red-400 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-red-800 dark:text-red-200">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-gray-600 dark:text-gray-300">No users with points found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or major..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg
|
||||
bg-white/90 dark:bg-gray-800/90 text-gray-700 dark:text-gray-200 focus:outline-none
|
||||
focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50/80 dark:bg-gray-800/80">
|
||||
<tr>
|
||||
<th scope="col" className="w-16 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
Rank
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th scope="col" className="w-24 px-6 py-3 text-center text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
Points
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white/90 dark:bg-gray-900/90 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{currentUsers.map((user, index) => {
|
||||
const actualRank = user.points > 0 ? indexOfFirstUser + index + 1 : null;
|
||||
const isCurrentUser = user.id === currentUserId;
|
||||
|
||||
return (
|
||||
<tr key={user.id} className={isCurrentUser ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
{actualRank ? (
|
||||
actualRank <= 3 ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8">
|
||||
{actualRank === 1 && <TrophyIcon className="text-yellow-500 w-6 h-6" />}
|
||||
{actualRank === 2 && <TrophyIcon className="text-gray-400 w-6 h-6" />}
|
||||
{actualRank === 3 && <TrophyIcon className="text-amber-700 w-6 h-6" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">{actualRank}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Not Ranked</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden relative">
|
||||
{user.avatar ? (
|
||||
<img className="h-10 w-10 rounded-full" src={user.avatar} alt={user.name} />
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-lg font-bold text-gray-700 dark:text-gray-300">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||
{user.name}
|
||||
</div>
|
||||
{user.major && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.major}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{user.points}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<nav className="flex items-center">
|
||||
<button
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-700
|
||||
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
onClick={() => paginate(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<button
|
||||
key={i + 1}
|
||||
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-700
|
||||
bg-white/90 dark:bg-gray-800/90 text-sm font-medium ${currentPage === i + 1
|
||||
? 'text-indigo-600 dark:text-indigo-400 border-indigo-500 dark:border-indigo-500 z-10'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={() => paginate(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-700
|
||||
bg-white/90 dark:bg-gray-800/90 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
onClick={() => paginate(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show current user rank if not in current page */}
|
||||
{isAuthenticated && currentUserRank && !currentUsers.some(user => user.id === currentUserId) && (
|
||||
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Your rank: <span className="font-bold text-indigo-600 dark:text-indigo-400">#{currentUserRank}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current user with 0 points */}
|
||||
{isAuthenticated && currentUserId &&
|
||||
!currentUserRank &&
|
||||
currentUsers.some(user => user.id === currentUserId) && (
|
||||
<div className="mt-4 p-3 bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Participate in events to earn points and get ranked!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -14,6 +14,13 @@ sections:
|
|||
component: "EventsSection"
|
||||
class: "text-secondary hover:text-secondary-focus"
|
||||
|
||||
leaderboard:
|
||||
title: "Leaderboard"
|
||||
icon: "heroicons:trophy"
|
||||
role: "none"
|
||||
component: "LeaderboardSection"
|
||||
class: "text-warning hover:text-warning-focus"
|
||||
|
||||
reimbursement:
|
||||
title: "Reimbursement"
|
||||
icon: "heroicons:credit-card"
|
||||
|
@ -91,7 +98,7 @@ sections:
|
|||
categories:
|
||||
main:
|
||||
title: "Main Menu"
|
||||
sections: ["profile", "events", "reimbursement"]
|
||||
sections: ["profile", "events", "leaderboard", "reimbursement"]
|
||||
role: "none"
|
||||
|
||||
officer:
|
||||
|
|
145
src/scripts/pocketbase/README.md
Normal file
145
src/scripts/pocketbase/README.md
Normal file
|
@ -0,0 +1,145 @@
|
|||
# PocketBase Integration
|
||||
|
||||
This directory contains the necessary scripts for interacting with PocketBase.
|
||||
|
||||
## Authentication
|
||||
|
||||
The `Authentication.ts` file handles user authentication, including login, logout, and token management.
|
||||
|
||||
## Data Retrieval
|
||||
|
||||
The `Get.ts` file provides methods for retrieving data from PocketBase collections.
|
||||
|
||||
## Data Updates
|
||||
|
||||
The `Update.ts` file provides methods for updating data in PocketBase collections.
|
||||
|
||||
## File Management
|
||||
|
||||
The `FileManager.ts` file handles file uploads and downloads.
|
||||
|
||||
## Realtime Subscriptions
|
||||
|
||||
The `Realtime.ts` file provides methods for subscribing to realtime changes in PocketBase collections and records.
|
||||
|
||||
### Usage
|
||||
|
||||
#### Subscribe to a Collection
|
||||
|
||||
Subscribe to changes in an entire collection:
|
||||
|
||||
```typescript
|
||||
import { Realtime } from "../scripts/pocketbase/Realtime";
|
||||
import { Collections } from "../schemas/pocketbase";
|
||||
import type { Event } from "../schemas/pocketbase/schema";
|
||||
|
||||
// Define the RealtimeEvent type for proper typing
|
||||
interface RealtimeEvent<T> {
|
||||
action: "create" | "update" | "delete";
|
||||
record: T;
|
||||
}
|
||||
|
||||
// Get the singleton instance
|
||||
const realtime = Realtime.getInstance();
|
||||
|
||||
// Subscribe to all event changes
|
||||
const subscriptionId = realtime.subscribeToCollection<RealtimeEvent<Event>>(
|
||||
Collections.EVENTS,
|
||||
(data) => {
|
||||
console.log(`Event ${data.action}:`, data.record);
|
||||
|
||||
// Handle different actions
|
||||
switch (data.action) {
|
||||
case "create":
|
||||
console.log("New event created:", data.record.event_name);
|
||||
break;
|
||||
case "update":
|
||||
console.log("Event updated:", data.record.event_name);
|
||||
break;
|
||||
case "delete":
|
||||
console.log("Event deleted:", data.record.id);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Later, when you're done with the subscription
|
||||
realtime.unsubscribe(subscriptionId);
|
||||
```
|
||||
|
||||
#### Subscribe to a Specific Record
|
||||
|
||||
Subscribe to changes for a specific record:
|
||||
|
||||
```typescript
|
||||
// Subscribe to a specific event
|
||||
const eventId = "your_event_id";
|
||||
const specificEventSubscriptionId = realtime.subscribeToRecord<
|
||||
RealtimeEvent<Event>
|
||||
>(
|
||||
Collections.EVENTS,
|
||||
eventId,
|
||||
(data) => {
|
||||
console.log(`Specific event ${data.action}:`, data.record);
|
||||
},
|
||||
{ expand: "attendees" }, // Optional: expand relations
|
||||
);
|
||||
|
||||
// Later, when you're done with the subscription
|
||||
realtime.unsubscribe(specificEventSubscriptionId);
|
||||
```
|
||||
|
||||
#### Using in React Components
|
||||
|
||||
```tsx
|
||||
import { useEffect } from "react";
|
||||
import { Realtime } from "../scripts/pocketbase/Realtime";
|
||||
import { Collections } from "../schemas/pocketbase";
|
||||
import type { Event } from "../schemas/pocketbase/schema";
|
||||
|
||||
// Define the RealtimeEvent type for proper typing
|
||||
interface RealtimeEvent<T> {
|
||||
action: "create" | "update" | "delete";
|
||||
record: T;
|
||||
}
|
||||
|
||||
function EventsComponent() {
|
||||
useEffect(() => {
|
||||
const realtime = Realtime.getInstance();
|
||||
|
||||
// Subscribe to events collection
|
||||
const subscriptionId = realtime.subscribeToCollection<RealtimeEvent<Event>>(
|
||||
Collections.EVENTS,
|
||||
(data) => {
|
||||
// Handle the realtime update
|
||||
console.log(`Event ${data.action}:`, data.record);
|
||||
|
||||
// Update your component state here
|
||||
// For example:
|
||||
// if (data.action === 'create') {
|
||||
// setEvents(prevEvents => [...prevEvents, data.record]);
|
||||
// }
|
||||
},
|
||||
);
|
||||
|
||||
// Cleanup function to unsubscribe when component unmounts
|
||||
return () => {
|
||||
realtime.unsubscribe(subscriptionId);
|
||||
};
|
||||
}, []); // Empty dependency array means this runs once when component mounts
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Events</h1>
|
||||
{/* Your component JSX */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Unsubscribe from All Subscriptions
|
||||
|
||||
```typescript
|
||||
// Unsubscribe from all subscriptions at once
|
||||
realtime.unsubscribeAll();
|
||||
```
|
196
src/scripts/pocketbase/Realtime.ts
Normal file
196
src/scripts/pocketbase/Realtime.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
import { Authentication } from "./Authentication";
|
||||
import { Collections } from "../../schemas/pocketbase";
|
||||
import { Get } from "./Get";
|
||||
|
||||
// Type for subscription callbacks
|
||||
type SubscriptionCallback<T> = (data: T) => void;
|
||||
|
||||
// Type for realtime event data
|
||||
interface RealtimeEvent<T> {
|
||||
action: "create" | "update" | "delete";
|
||||
record: T;
|
||||
}
|
||||
|
||||
// Interface for subscription options
|
||||
interface SubscriptionOptions {
|
||||
expand?: string[] | string;
|
||||
}
|
||||
|
||||
export class Realtime {
|
||||
private auth: Authentication;
|
||||
private static instance: Realtime;
|
||||
private subscriptions: Map<string, { callbacks: Set<SubscriptionCallback<any>>, options?: SubscriptionOptions }>;
|
||||
private getService: Get;
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
this.getService = Get.getInstance();
|
||||
this.subscriptions = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of Realtime
|
||||
*/
|
||||
public static getInstance(): Realtime {
|
||||
if (!Realtime.instance) {
|
||||
Realtime.instance = new Realtime();
|
||||
}
|
||||
return Realtime.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to collection changes
|
||||
* @param collectionName The name of the collection to subscribe to
|
||||
* @param callback Function to call when changes occur
|
||||
* @param options Subscription options like expanding relations
|
||||
* @returns Subscription ID
|
||||
*/
|
||||
public subscribeToCollection<T>(
|
||||
collectionName: string,
|
||||
callback: SubscriptionCallback<T>,
|
||||
options?: SubscriptionOptions
|
||||
): string {
|
||||
const subscriptionId = collectionName;
|
||||
|
||||
// Register the callback
|
||||
if (!this.subscriptions.has(subscriptionId)) {
|
||||
this.subscriptions.set(subscriptionId, {
|
||||
callbacks: new Set([callback]),
|
||||
options
|
||||
});
|
||||
this.initializeSubscription(collectionName);
|
||||
} else {
|
||||
this.subscriptions.get(subscriptionId)!.callbacks.add(callback);
|
||||
}
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific record's changes
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId The ID of the record to subscribe to
|
||||
* @param callback Function to call when changes occur
|
||||
* @param options Subscription options like expanding relations
|
||||
* @returns Subscription ID
|
||||
*/
|
||||
public subscribeToRecord<T>(
|
||||
collectionName: string,
|
||||
recordId: string,
|
||||
callback: SubscriptionCallback<T>,
|
||||
options?: SubscriptionOptions
|
||||
): string {
|
||||
const subscriptionId = `${collectionName}/${recordId}`;
|
||||
|
||||
// Register the callback
|
||||
if (!this.subscriptions.has(subscriptionId)) {
|
||||
this.subscriptions.set(subscriptionId, {
|
||||
callbacks: new Set([callback]),
|
||||
options
|
||||
});
|
||||
this.initializeSubscription(collectionName, recordId);
|
||||
} else {
|
||||
this.subscriptions.get(subscriptionId)!.callbacks.add(callback);
|
||||
}
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all or specific collection/record changes
|
||||
* @param subscriptionId The subscription ID to unsubscribe from
|
||||
* @param callback Optional specific callback to remove
|
||||
*/
|
||||
public unsubscribe(subscriptionId: string, callback?: SubscriptionCallback<any>): void {
|
||||
if (!this.subscriptions.has(subscriptionId)) return;
|
||||
|
||||
if (callback) {
|
||||
// Remove specific callback
|
||||
this.subscriptions.get(subscriptionId)!.callbacks.delete(callback);
|
||||
|
||||
// If no callbacks remain, remove the entire subscription
|
||||
if (this.subscriptions.get(subscriptionId)!.callbacks.size === 0) {
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
this.removeSubscription(subscriptionId);
|
||||
}
|
||||
} else {
|
||||
// Remove entire subscription
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
this.removeSubscription(subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a subscription to a collection or record
|
||||
* @param collectionName The name of the collection
|
||||
* @param recordId Optional record ID to subscribe to
|
||||
*/
|
||||
private initializeSubscription(collectionName: string, recordId?: string): void {
|
||||
const pb = this.auth.getPocketBase();
|
||||
const topic = recordId ? `${collectionName}/${recordId}` : collectionName;
|
||||
|
||||
// Subscribe to the topic
|
||||
pb.collection(collectionName).subscribe(recordId || "*", async (data) => {
|
||||
const event = data as unknown as RealtimeEvent<any>;
|
||||
const subscriptionId = recordId ? `${collectionName}/${recordId}` : collectionName;
|
||||
|
||||
if (!this.subscriptions.has(subscriptionId)) return;
|
||||
|
||||
const subscription = this.subscriptions.get(subscriptionId)!;
|
||||
const callbacks = subscription.callbacks;
|
||||
|
||||
// Process the event data (convert UTC dates to local, expand relations if needed)
|
||||
let processedRecord = Get.convertUTCToLocal(event.record);
|
||||
|
||||
// If there are any expansion options, fetch the expanded record
|
||||
if (subscription.options?.expand && recordId) {
|
||||
try {
|
||||
const expandOptions = { expand: subscription.options.expand };
|
||||
const expandedRecord = await this.getService.getOne(collectionName, recordId, expandOptions);
|
||||
processedRecord = expandedRecord;
|
||||
} catch (error) {
|
||||
console.error("Error expanding record in realtime event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify all callbacks
|
||||
callbacks.forEach(callback => {
|
||||
callback({
|
||||
action: event.action,
|
||||
record: processedRecord
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription
|
||||
* @param subscriptionId The subscription ID to remove
|
||||
*/
|
||||
private removeSubscription(subscriptionId: string): void {
|
||||
const pb = this.auth.getPocketBase();
|
||||
const [collectionName, recordId] = subscriptionId.split('/');
|
||||
|
||||
if (collectionName) {
|
||||
pb.collection(collectionName).unsubscribe(recordId || '*');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all subscriptions
|
||||
*/
|
||||
public unsubscribeAll(): void {
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Unsubscribe from all topics
|
||||
this.subscriptions.forEach((_, subscriptionId) => {
|
||||
const [collectionName, recordId] = subscriptionId.split('/');
|
||||
if (collectionName) {
|
||||
pb.collection(collectionName).unsubscribe(recordId || '*');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the subscriptions map
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
}
|
14
src/scripts/pocketbase/index.ts
Normal file
14
src/scripts/pocketbase/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* PocketBase Scripts Index
|
||||
*
|
||||
* This file exports all the PocketBase service classes
|
||||
* for easier imports throughout the codebase.
|
||||
*/
|
||||
|
||||
// Export all PocketBase services
|
||||
export { Authentication } from "./Authentication";
|
||||
export { Get } from "./Get";
|
||||
export { Update } from "./Update";
|
||||
export { FileManager } from "./FileManager";
|
||||
export { Realtime } from "./Realtime";
|
||||
export { SendLog } from "./SendLog";
|
Loading…
Reference in a new issue