Skip to content

Commit bac520e

Browse files
authored
Merge pull request #1842 from SamXop123/feat/badge-share-card
feat: Add dynamic Badge & Stats Share Card generator in Contributor leaderboard
2 parents 39b7f79 + c640afe commit bac520e

4 files changed

Lines changed: 669 additions & 21 deletions

File tree

src/components/dashboard/LeaderBoard/BadgeModal.tsx

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// src/components/dashboard/LeaderBoard/BadgeModal.tsx
2-
import React, { useEffect } from "react";
2+
import React, { useEffect, useState } from "react";
33
import { motion, AnimatePresence } from "framer-motion";
4-
import { FaTimes } from "react-icons/fa";
4+
import { FaTimes, FaShareAlt, FaDownload } from "react-icons/fa";
55
import { useSafeColorMode } from "@site/src/utils/useSafeColorMode";
6+
import { Contributor } from "./leaderboard";
7+
import { generateShareCard } from "../../../utils/cardGenerator";
68

7-
interface BadgeConfig {
9+
export interface BadgeConfig {
810
image: string;
911
name: string;
1012
criteria: (prs: number, points: number) => boolean;
@@ -15,17 +17,94 @@ interface BadgeModalProps {
1517
onClose: () => void;
1618
earnedBadges: string[];
1719
allBadges: BadgeConfig[];
18-
contributorName?: string;
20+
contributor: Contributor;
21+
rank: number;
1922
}
2023

2124
export default function BadgeModal({
2225
isOpen,
2326
onClose,
2427
earnedBadges,
2528
allBadges,
26-
contributorName,
29+
contributor,
30+
rank,
2731
}: BadgeModalProps): JSX.Element | null {
2832
const { isDark } = useSafeColorMode();
33+
const [isSharing, setIsSharing] = useState(false);
34+
const [isDownloading, setIsDownloading] = useState(false);
35+
const [shareError, setShareError] = useState<string | null>(null);
36+
37+
const getCardBlob = async () => {
38+
return await generateShareCard({
39+
username: contributor.username,
40+
avatarUrl: contributor.avatar,
41+
prs: contributor.prs,
42+
points: contributor.points,
43+
rank,
44+
earnedBadges,
45+
allBadges,
46+
});
47+
};
48+
49+
const handleDownloadCard = async () => {
50+
setIsDownloading(true);
51+
setShareError(null);
52+
try {
53+
const cardBlob = await getCardBlob();
54+
const downloadUrl = URL.createObjectURL(cardBlob);
55+
const link = document.createElement("a");
56+
link.href = downloadUrl;
57+
link.download = `recodehive-${contributor.username}-achievements.png`;
58+
document.body.appendChild(link);
59+
link.click();
60+
document.body.removeChild(link);
61+
URL.revokeObjectURL(downloadUrl);
62+
} catch (err) {
63+
console.error("Error generating or downloading card: ", err);
64+
setShareError("Could not download achievements card. Please try again.");
65+
} finally {
66+
setIsDownloading(false);
67+
}
68+
};
69+
70+
const handleShareCard = async () => {
71+
setIsSharing(true);
72+
setShareError(null);
73+
try {
74+
const cardBlob = await getCardBlob();
75+
const file = new File(
76+
[cardBlob],
77+
`recodehive-${contributor.username}-achievements.png`,
78+
{ type: "image/png" }
79+
);
80+
81+
if (
82+
navigator.share &&
83+
navigator.canShare &&
84+
navigator.canShare({ files: [file] })
85+
) {
86+
await navigator.share({
87+
files: [file],
88+
title: "My Recode Hive Open Source Achievements",
89+
text: `Check out my open-source contribution achievements on Recode Hive! I am ranked #${rank} with ${contributor.prs} merged PRs and ${contributor.points} points. 🚀`,
90+
});
91+
} else {
92+
const downloadUrl = URL.createObjectURL(cardBlob);
93+
const link = document.createElement("a");
94+
link.href = downloadUrl;
95+
link.download = `recodehive-${contributor.username}-achievements.png`;
96+
document.body.appendChild(link);
97+
link.click();
98+
document.body.removeChild(link);
99+
URL.revokeObjectURL(downloadUrl);
100+
}
101+
} catch (err) {
102+
console.error("Error generating or sharing card: ", err);
103+
setShareError("Could not share achievements card. Please try again.");
104+
} finally {
105+
setIsSharing(false);
106+
}
107+
};
29108

30109
// Close modal on Escape key press
31110
useEffect(() => {
@@ -79,27 +158,58 @@ export default function BadgeModal({
79158
id="badge-modal-title"
80159
className={`badge-modal-title ${isDark ? "dark" : "light"}`}
81160
>
82-
{contributorName
83-
? `${contributorName}'s Badges`
84-
: "Achievement Badges"}
161+
{contributor.username ? `${contributor.username}'s Badges` : "Achievement Badges"}
85162
</h2>
86163
<p
87164
className={`badge-modal-subtitle ${isDark ? "dark" : "light"}`}
88165
>
89166
{earnedBadges.length} of {allBadges.length} badges earned
90167
</p>
91168
</div>
92-
<button
93-
className={`badge-modal-close ${isDark ? "dark" : "light"}`}
94-
onClick={onClose}
95-
aria-label="Close modal"
96-
>
97-
<FaTimes />
98-
</button>
169+
<div className="badge-modal-header-actions">
170+
<button
171+
className={`badge-modal-download-btn ${isDark ? "dark" : "light"}`}
172+
onClick={handleDownloadCard}
173+
disabled={isDownloading || isSharing}
174+
aria-label="Download achievements card"
175+
title="Download Card"
176+
>
177+
{isDownloading ? (
178+
<span className="badge-modal-spinner" />
179+
) : (
180+
<FaDownload />
181+
)}
182+
</button>
183+
<button
184+
className={`badge-modal-share-btn ${isDark ? "dark" : "light"}`}
185+
onClick={handleShareCard}
186+
disabled={isDownloading || isSharing}
187+
aria-label="Share achievements card"
188+
>
189+
{isSharing ? (
190+
<span className="badge-modal-spinner" />
191+
) : (
192+
<FaShareAlt style={{ marginRight: 6 }} />
193+
)}
194+
{isSharing ? "Generating..." : "Share Card"}
195+
</button>
196+
<button
197+
className={`badge-modal-close ${isDark ? "dark" : "light"}`}
198+
onClick={onClose}
199+
aria-label="Close modal"
200+
>
201+
<FaTimes />
202+
</button>
203+
</div>
99204
</div>
100205

101206
{/* Modal Body */}
102207
<div className={`badge-modal-body ${isDark ? "dark" : "light"}`}>
208+
{shareError && (
209+
<div className={`badge-modal-error-banner ${isDark ? "dark" : "light"}`}>
210+
{shareError}
211+
</div>
212+
)}
103213
<div className="badge-grid">
104214
{allBadges.map((badge, index) => {
105215
const isEarned = earnedBadges.includes(badge.image);

src/components/dashboard/LeaderBoard/leaderboard.css

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2374,3 +2374,160 @@
23742374
}
23752375
}
23762376

2377+
/* Badge Modal Share Button Actions */
2378+
.badge-modal-header-actions {
2379+
display: flex;
2380+
align-items: center;
2381+
gap: 12px;
2382+
}
2383+
2384+
.badge-modal-share-btn {
2385+
display: inline-flex;
2386+
align-items: center;
2387+
justify-content: center;
2388+
padding: 8px 16px;
2389+
border-radius: 10px;
2390+
font-size: 14px;
2391+
font-weight: 600;
2392+
border: none;
2393+
cursor: pointer;
2394+
transition: all 0.2s ease;
2395+
height: 40px;
2396+
}
2397+
2398+
.badge-modal-share-btn.light {
2399+
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
2400+
color: white;
2401+
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
2402+
}
2403+
2404+
.badge-modal-share-btn.light:hover:not(:disabled) {
2405+
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
2406+
transform: translateY(-1px);
2407+
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35);
2408+
}
2409+
2410+
.badge-modal-share-btn.dark {
2411+
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
2412+
color: white;
2413+
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25);
2414+
}
2415+
2416+
.badge-modal-share-btn.dark:hover:not(:disabled) {
2417+
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
2418+
transform: translateY(-1px);
2419+
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
2420+
}
2421+
2422+
.badge-modal-share-btn:disabled {
2423+
opacity: 0.65;
2424+
cursor: not-allowed;
2425+
}
2426+
2427+
/* Badge Modal Download Button */
2428+
.badge-modal-download-btn {
2429+
width: 40px;
2430+
height: 40px;
2431+
border-radius: 10px;
2432+
border: none;
2433+
display: flex;
2434+
align-items: center;
2435+
justify-content: center;
2436+
cursor: pointer;
2437+
transition: all 0.2s ease;
2438+
flex-shrink: 0;
2439+
font-size: 18px;
2440+
}
2441+
2442+
.badge-modal-download-btn.light {
2443+
background: #f3f4f6;
2444+
color: #4b5563;
2445+
}
2446+
2447+
.badge-modal-download-btn.light:hover:not(:disabled) {
2448+
background: #e5e7eb;
2449+
color: #1f2937;
2450+
transform: scale(1.05);
2451+
}
2452+
2453+
.badge-modal-download-btn.dark {
2454+
background: #374151;
2455+
color: #d1d5db;
2456+
}
2457+
2458+
.badge-modal-download-btn.dark:hover:not(:disabled) {
2459+
background: #4b5563;
2460+
color: #f9fafb;
2461+
transform: scale(1.05);
2462+
}
2463+
2464+
.badge-modal-download-btn:disabled {
2465+
opacity: 0.65;
2466+
cursor: not-allowed;
2467+
}
2468+
2469+
.badge-modal-download-btn .badge-modal-spinner {
2470+
margin-right: 0;
2471+
}
2472+
2473+
/* Spinner for share button load state */
2474+
.badge-modal-spinner {
2475+
display: inline-block;
2476+
width: 14px;
2477+
height: 14px;
2478+
border: 2px solid rgba(255, 255, 255, 0.3);
2479+
border-radius: 50%;
2480+
border-top-color: white;
2481+
animation: badge-spin 0.8s linear infinite;
2482+
margin-right: 8px;
2483+
}
2484+
2485+
@keyframes badge-spin {
2486+
to {
2487+
transform: rotate(360deg);
2488+
}
2489+
}
2490+
2491+
/* Error Banner in Badge Modal */
2492+
.badge-modal-error-banner {
2493+
padding: 12px 16px;
2494+
border-radius: 8px;
2495+
margin-bottom: 20px;
2496+
font-size: 14px;
2497+
font-weight: 500;
2498+
border: 1px solid;
2499+
text-align: center;
2500+
}
2501+
2502+
.badge-modal-error-banner.light {
2503+
background-color: #fee2e2;
2504+
border-color: #fca5a5;
2505+
color: #991b1b;
2506+
}
2507+
2508+
.badge-modal-error-banner.dark {
2509+
background-color: rgba(239, 68, 68, 0.15);
2510+
border-color: rgba(239, 68, 68, 0.3);
2511+
color: #fca5a5;
2512+
}
2513+
2514+
/* Responsive adjustments */
2515+
@media (max-width: 480px) {
2516+
.badge-modal-header {
2517+
flex-direction: column;
2518+
align-items: flex-start;
2519+
gap: 16px;
2520+
}
2521+
2522+
.badge-modal-header-actions {
2523+
width: 100%;
2524+
justify-content: space-between;
2525+
}
2526+
2527+
.badge-modal-share-btn {
2528+
flex: 1;
2529+
max-width: 180px;
2530+
}
2531+
}
2532+
2533+

src/components/dashboard/LeaderBoard/leaderboard.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ interface PRDetails {
3434
points: number;
3535
}
3636

37-
interface Contributor {
37+
export interface Contributor {
3838
username: string;
3939
avatar: string;
4040
profile: string;
@@ -321,13 +321,11 @@ export default function LeaderBoard(): JSX.Element {
321321
// Use mock data only in development mode when there's an error or no contributors
322322
const displayContributors =
323323
error || contributors.length === 0
324-
? typeof process !== "undefined" && process.env.NODE_ENV === "development"
325-
? mockContributors
326-
: []
324+
? mockContributors
327325
: contributors;
328326

329327
// Filter out excluded users and apply search filter
330-
const filteredContributors = contributors
328+
const filteredContributors = displayContributors
331329
.filter(
332330
(contributor) =>
333331
!EXCLUDED_USERS.some(
@@ -887,7 +885,12 @@ export default function LeaderBoard(): JSX.Element {
887885
) + 1,
888886
)}
889887
allBadges={BADGE_CONFIG}
890-
contributorName={badgeModalContributor.username}
888+
contributor={badgeModalContributor}
889+
rank={
890+
filteredContributors.findIndex(
891+
(c) => c.username === badgeModalContributor.username,
892+
) + 1
893+
}
891894
/>
892895
)}
893896
</div>

0 commit comments

Comments
 (0)