Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 125 additions & 15 deletions src/components/dashboard/LeaderBoard/BadgeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// src/components/dashboard/LeaderBoard/BadgeModal.tsx
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { FaTimes } from "react-icons/fa";
import { FaTimes, FaShareAlt, FaDownload } from "react-icons/fa";
import { useSafeColorMode } from "@site/src/utils/useSafeColorMode";
import { Contributor } from "./leaderboard";
import { generateShareCard } from "../../../utils/cardGenerator";

interface BadgeConfig {
export interface BadgeConfig {
image: string;
name: string;
criteria: (prs: number, points: number) => boolean;
Expand All @@ -15,17 +17,94 @@ interface BadgeModalProps {
onClose: () => void;
earnedBadges: string[];
allBadges: BadgeConfig[];
contributorName?: string;
contributor: Contributor;
rank: number;
}

export default function BadgeModal({
isOpen,
onClose,
earnedBadges,
allBadges,
contributorName,
contributor,
rank,
}: BadgeModalProps): JSX.Element | null {
const { isDark } = useSafeColorMode();
const [isSharing, setIsSharing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);

const getCardBlob = async () => {
return await generateShareCard({
username: contributor.username,
avatarUrl: contributor.avatar,
prs: contributor.prs,
points: contributor.points,
rank,
earnedBadges,
allBadges,
});
};

const handleDownloadCard = async () => {
setIsDownloading(true);
setShareError(null);
try {
const cardBlob = await getCardBlob();
const downloadUrl = URL.createObjectURL(cardBlob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `recodehive-${contributor.username}-achievements.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
} catch (err) {
console.error("Error generating or downloading card: ", err);
setShareError("Could not download achievements card. Please try again.");
} finally {
setIsDownloading(false);
}
};

const handleShareCard = async () => {
setIsSharing(true);
setShareError(null);
try {
const cardBlob = await getCardBlob();
const file = new File(
[cardBlob],
`recodehive-${contributor.username}-achievements.png`,
{ type: "image/png" }
);

if (
navigator.share &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
await navigator.share({
files: [file],
title: "My Recode Hive Open Source Achievements",
text: `Check out my open-source contribution achievements on Recode Hive! I am ranked #${rank} with ${contributor.prs} merged PRs and ${contributor.points} points. 🚀`,
});
} else {
const downloadUrl = URL.createObjectURL(cardBlob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `recodehive-${contributor.username}-achievements.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
}
} catch (err) {
console.error("Error generating or sharing card: ", err);
setShareError("Could not share achievements card. Please try again.");
} finally {
setIsSharing(false);
}
};

// Close modal on Escape key press
useEffect(() => {
Expand Down Expand Up @@ -79,27 +158,58 @@ export default function BadgeModal({
id="badge-modal-title"
className={`badge-modal-title ${isDark ? "dark" : "light"}`}
>
{contributorName
? `${contributorName}'s Badges`
: "Achievement Badges"}
{contributor.username ? `${contributor.username}'s Badges` : "Achievement Badges"}
</h2>
<p
className={`badge-modal-subtitle ${isDark ? "dark" : "light"}`}
>
{earnedBadges.length} of {allBadges.length} badges earned
</p>
</div>
<button
className={`badge-modal-close ${isDark ? "dark" : "light"}`}
onClick={onClose}
aria-label="Close modal"
>
<FaTimes />
</button>
<div className="badge-modal-header-actions">
<button
className={`badge-modal-download-btn ${isDark ? "dark" : "light"}`}
onClick={handleDownloadCard}
disabled={isDownloading || isSharing}
aria-label="Download achievements card"
title="Download Card"
>
{isDownloading ? (
<span className="badge-modal-spinner" />
) : (
<FaDownload />
)}
</button>
<button
className={`badge-modal-share-btn ${isDark ? "dark" : "light"}`}
onClick={handleShareCard}
disabled={isDownloading || isSharing}
aria-label="Share achievements card"
>
{isSharing ? (
<span className="badge-modal-spinner" />
) : (
<FaShareAlt style={{ marginRight: 6 }} />
)}
{isSharing ? "Generating..." : "Share Card"}
</button>
<button
className={`badge-modal-close ${isDark ? "dark" : "light"}`}
onClick={onClose}
aria-label="Close modal"
>
<FaTimes />
</button>
</div>
</div>

{/* Modal Body */}
<div className={`badge-modal-body ${isDark ? "dark" : "light"}`}>
{shareError && (
<div className={`badge-modal-error-banner ${isDark ? "dark" : "light"}`}>
{shareError}
</div>
)}
<div className="badge-grid">
{allBadges.map((badge, index) => {
const isEarned = earnedBadges.includes(badge.image);
Expand Down
157 changes: 157 additions & 0 deletions src/components/dashboard/LeaderBoard/leaderboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -2374,3 +2374,160 @@
}
}

/* Badge Modal Share Button Actions */
.badge-modal-header-actions {
display: flex;
align-items: center;
gap: 12px;
}

.badge-modal-share-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s ease;
height: 40px;
}

.badge-modal-share-btn.light {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}

.badge-modal-share-btn.light:hover:not(:disabled) {
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35);
}

.badge-modal-share-btn.dark {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25);
}

.badge-modal-share-btn.dark:hover:not(:disabled) {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
}

.badge-modal-share-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}

/* Badge Modal Download Button */
.badge-modal-download-btn {
width: 40px;
height: 40px;
border-radius: 10px;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
font-size: 18px;
}

.badge-modal-download-btn.light {
background: #f3f4f6;
color: #4b5563;
}

.badge-modal-download-btn.light:hover:not(:disabled) {
background: #e5e7eb;
color: #1f2937;
transform: scale(1.05);
}

.badge-modal-download-btn.dark {
background: #374151;
color: #d1d5db;
}

.badge-modal-download-btn.dark:hover:not(:disabled) {
background: #4b5563;
color: #f9fafb;
transform: scale(1.05);
}

.badge-modal-download-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}

.badge-modal-download-btn .badge-modal-spinner {
margin-right: 0;
}

/* Spinner for share button load state */
.badge-modal-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: badge-spin 0.8s linear infinite;
margin-right: 8px;
}

@keyframes badge-spin {
to {
transform: rotate(360deg);
}
}

/* Error Banner in Badge Modal */
.badge-modal-error-banner {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
font-weight: 500;
border: 1px solid;
text-align: center;
}

.badge-modal-error-banner.light {
background-color: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}

.badge-modal-error-banner.dark {
background-color: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}

/* Responsive adjustments */
@media (max-width: 480px) {
.badge-modal-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}

.badge-modal-header-actions {
width: 100%;
justify-content: space-between;
}

.badge-modal-share-btn {
flex: 1;
max-width: 180px;
}
}


15 changes: 9 additions & 6 deletions src/components/dashboard/LeaderBoard/leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ interface PRDetails {
points: number;
}

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

// Filter out excluded users and apply search filter
const filteredContributors = contributors
const filteredContributors = displayContributors
.filter(
(contributor) =>
!EXCLUDED_USERS.some(
Expand Down Expand Up @@ -887,7 +885,12 @@ export default function LeaderBoard(): JSX.Element {
) + 1,
)}
allBadges={BADGE_CONFIG}
contributorName={badgeModalContributor.username}
contributor={badgeModalContributor}
rank={
filteredContributors.findIndex(
(c) => c.username === badgeModalContributor.username,
) + 1
}
/>
)}
</div>
Expand Down
Loading
Loading