diff --git a/package-lock.json b/package-lock.json index d3372739..51d8badb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.40.0", + "html-to-image": "^1.11.13", "lucide-react": "^0.503.0", "prism-react-renderer": "^2.3.0", "react": "^18.3.1", @@ -14059,6 +14060,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", diff --git a/package.json b/package.json index 3bb97283..939de2b6 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.40.0", + "html-to-image": "^1.11.13", "lucide-react": "^0.503.0", "prism-react-renderer": "^2.3.0", "react": "^18.3.1", diff --git a/src/components/dashboard/LeaderBoard/ContributorCard.tsx b/src/components/dashboard/LeaderBoard/ContributorCard.tsx new file mode 100644 index 00000000..14c5c652 --- /dev/null +++ b/src/components/dashboard/LeaderBoard/ContributorCard.tsx @@ -0,0 +1,113 @@ +// src/components/dashboard/LeaderBoard/ContributorCard.tsx +import React, { useRef, useState } from "react"; +import { toPng } from "html-to-image"; +import { FaGithub, FaDownload, FaTrophy } from "react-icons/fa"; + +interface ContributorCardProps { + username: string; + avatar: string; + prs: number; + points: number; + rank: number; + badges: string[]; + isDark: boolean; +} + +export default function ContributorCard({ + username, + avatar, + prs, + points, + rank, + badges, + isDark, +}: ContributorCardProps) { + const [open, setOpen] = useState(false); + const cardRef = useRef(null); + + const handleDownload = async () => { + if (!cardRef.current) return; + const dataUrl = await toPng(cardRef.current, { cacheBust: true }); + const link = document.createElement("a"); + link.download = `${username}-recodehive-card.png`; + link.href = dataUrl; + link.click(); + }; + + return ( + <> + {/* Trigger button — sits right of "Contribute on GitHub" */} + + + {/* Modal overlay */} + {open && ( +
setOpen(false)} + > +
e.stopPropagation()} + > + {/* The actual downloadable card */} +
+
+ RecodeHive + recodehive +
+ {username} +

{username}

+
#{rank} on Leaderboard
+
+
+ {prs} + PRs Merged +
+
+ {points} + Points +
+
+ {badges.length > 0 && ( +
+ {badges.slice(0, 5).map((badge, i) => ( + {`badge-${i}`} + ))} +
+ )} +
+ github.com/recodehive +
+
+ + {/* Action buttons */} +
+ + +
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/dashboard/LeaderBoard/leaderboard.css b/src/components/dashboard/LeaderBoard/leaderboard.css index 44812754..a69dae19 100644 --- a/src/components/dashboard/LeaderBoard/leaderboard.css +++ b/src/components/dashboard/LeaderBoard/leaderboard.css @@ -37,11 +37,11 @@ .header { margin-bottom: 32px; } - + .title { font-size: 24px; } - + .subtitle { font-size: 15px; } @@ -51,7 +51,7 @@ .title { font-size: 20px; } - + .subtitle { font-size: 14px; } @@ -92,7 +92,7 @@ flex-direction: column; gap: 12px; } - + .top-performers-title { font-size: 20px; } @@ -136,19 +136,19 @@ gap: 20px; padding: 20px 10px; } - + .podium-card { width: 100%; max-width: 280px; transform: none !important; } - + .first-place, .second-place, .third-place { transform: none !important; } - + .first-place:hover, .second-place:hover, .third-place:hover { @@ -186,9 +186,17 @@ } /* Staircase Height Adjustment */ -.first-place { transform: translateY(-30px); } -.second-place { transform: translateY(0px); } -.third-place { transform: translateY(0px); } +.first-place { + transform: translateY(-30px); +} + +.second-place { + transform: translateY(0px); +} + +.third-place { + transform: translateY(0px); +} /* Reflection Effect */ .podium-card::after { @@ -199,35 +207,33 @@ height: 25%; width: 100%; border-radius: 0 0 16px 16px; - background: linear-gradient( - to bottom, - rgba(255, 255, 255, 0.4) 0%, - rgba(255, 255, 255, 0.3) 25%, - rgba(255, 255, 255, 0.1) 75%, - rgba(255, 255, 255, 0) 100% - ); + background: linear-gradient(to bottom, + rgba(255, 255, 255, 0.4) 0%, + rgba(255, 255, 255, 0.3) 25%, + rgba(255, 255, 255, 0.1) 75%, + rgba(255, 255, 255, 0) 100%); transform: scaleY(-1); z-index: 1; pointer-events: none; } .dark .podium-card::after { - background: linear-gradient( - to bottom, - rgba(43, 48, 59, 0.4) 0%, - rgba(43, 48, 59, 0.3) 25%, - rgba(43, 48, 59, 0.1) 75%, - rgba(43, 48, 59, 0) 100% - ); + background: linear-gradient(to bottom, + rgba(43, 48, 59, 0.4) 0%, + rgba(43, 48, 59, 0.3) 25%, + rgba(43, 48, 59, 0.1) 75%, + rgba(43, 48, 59, 0) 100%); } /* Reflection box-shadows */ .first-place::after { box-shadow: 0 2px 6px rgba(255, 215, 0, 0.1); } + .second-place::after { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); } + .third-place::after { box-shadow: 0 2px 6px rgba(205, 127, 50, 0.1); } @@ -289,9 +295,11 @@ .first-place .rank-badge { background-color: #ffd700; } + .second-place .rank-badge { background-color: #555; } + .third-place .rank-badge { background-color: #cd7f32; } @@ -324,7 +332,7 @@ .podium-card .stats { gap: 6px; } - + .podium-card .prs, .podium-card .points { font-size: 0.75em; @@ -604,11 +612,9 @@ font-weight: 600; letter-spacing: 0.3px; appearance: none; - background: linear-gradient( - to bottom, - rgba(255, 255, 255, 0.05) 0%, - rgba(0, 0, 0, 0.05) 100% - ); + background: linear-gradient(to bottom, + rgba(255, 255, 255, 0.05) 0%, + rgba(0, 0, 0, 0.05) 100%); cursor: pointer; transition: all 0.25s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); @@ -792,7 +798,7 @@ width: 36px; height: 36px; } - + .contributor-badges { gap: 6px; justify-content: center; @@ -804,7 +810,7 @@ width: 32px; height: 32px; } - + .contributor-badges { gap: 4px; } @@ -853,7 +859,7 @@ width: 32px; height: 32px; } - + .top-performer-card .contributor-badges { gap: 6px; } @@ -892,7 +898,7 @@ width: 28px; height: 28px; } - + .podium-card .contributor-badges { gap: 6px; flex-wrap: wrap; @@ -1303,7 +1309,7 @@ justify-self: center; margin: 4px 0; } - + .badges-cell { justify-self: center; width: 100%; @@ -1313,14 +1319,14 @@ margin-top: 4px; overflow: visible; } - + .contributor-badges { justify-content: center; max-width: 100%; flex-wrap: wrap; gap: 6px; } - + .contributor-badge-icon { width: 36px; height: 36px; @@ -1332,16 +1338,16 @@ padding: 12px; gap: 10px; } - + .contributor-badge-icon { width: 32px; height: 32px; } - + .contributor-badges { gap: 4px; } - + .podium-card { max-width: 100%; padding: 16px; @@ -1379,6 +1385,7 @@ } @media (max-width: 475px) { + .contributors-container .page-numbers .page-btn, .contributors-container .pagination .pagination-btn, .contributors-container .pagination-ellipsis { @@ -1395,22 +1402,23 @@ } @media (max-width: 320px) { + .contributors-container .page-numbers .page-btn, .contributors-container .pagination .pagination-btn, .contributors-container .pagination-ellipsis { margin: 0 2px; } - + .contributor-badge-icon { width: 28px; height: 28px; } - + .podium-card .contributor-badge-icon { width: 20px; height: 20px; } - + .contributor-badges { gap: 3px; } @@ -1476,23 +1484,19 @@ width: 32px; height: 32px; border-radius: 50%; - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.06) 25%, - rgba(0, 0, 0, 0.12) 50%, - rgba(0, 0, 0, 0.06) 75% - ); + background: linear-gradient(90deg, + rgba(0, 0, 0, 0.06) 25%, + rgba(0, 0, 0, 0.12) 50%, + rgba(0, 0, 0, 0.06) 75%); background-size: 200% 100%; animation: skeleton-shimmer 2s infinite; } .skeleton-loader.dark .skeleton-avatar { - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.05) 25%, - rgba(255, 255, 255, 0.1) 50%, - rgba(255, 255, 255, 0.05) 75% - ); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.05) 25%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0.05) 75%); background-size: 200% 100%; } @@ -1512,23 +1516,19 @@ height: 16px; width: 60%; border-radius: 4px; - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.06) 25%, - rgba(0, 0, 0, 0.12) 50%, - rgba(0, 0, 0, 0.06) 75% - ); + background: linear-gradient(90deg, + rgba(0, 0, 0, 0.06) 25%, + rgba(0, 0, 0, 0.12) 50%, + rgba(0, 0, 0, 0.06) 75%); background-size: 200% 100%; animation: skeleton-shimmer 2s infinite; } .skeleton-loader.dark .skeleton-bar { - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.05) 25%, - rgba(255, 255, 255, 0.1) 50%, - rgba(255, 255, 255, 0.05) 75% - ); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.05) 25%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0.05) 75%); background-size: 200% 100%; } @@ -1541,23 +1541,19 @@ height: 20px; width: 60px; border-radius: 12px; - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.06) 25%, - rgba(0, 0, 0, 0.12) 50%, - rgba(0, 0, 0, 0.06) 75% - ); + background: linear-gradient(90deg, + rgba(0, 0, 0, 0.06) 25%, + rgba(0, 0, 0, 0.12) 50%, + rgba(0, 0, 0, 0.06) 75%); background-size: 200% 100%; animation: skeleton-shimmer 2s infinite; } .skeleton-loader.dark .skeleton-badge { - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.05) 25%, - rgba(255, 255, 255, 0.1) 50%, - rgba(255, 255, 255, 0.05) 75% - ); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.05) 25%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0.05) 75%); background-size: 200% 100%; } @@ -2268,10 +2264,13 @@ } @keyframes pulse { - 0%, 100% { + + 0%, + 100% { transform: scale(1); box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4); } + 50% { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.6); @@ -2374,3 +2373,117 @@ } } +/* Achievement Card */ +.card-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.card-modal-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.achievement-card { + width: 320px; + border-radius: 16px; + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + background: linear-gradient(135deg, #1e3a5f, #0f172a); + color: white; + font-family: 'Inter', sans-serif; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.achievement-card.light { + background: linear-gradient(135deg, #dbeafe, #ede9fe); + color: #1e1e2e; +} + +.achievement-card-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + opacity: 0.8; +} + +.card-logo { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.card-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + border: 3px solid #7c3aed; +} + +.card-username { + font-size: 20px; + font-weight: 700; + margin: 0; +} + +.card-rank { + font-size: 13px; + opacity: 0.7; +} + +.card-stats { + display: flex; + gap: 24px; + margin: 8px 0; +} + +.card-stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.card-stat-value { + font-size: 22px; + font-weight: 800; + color: #a78bfa; +} + +.card-stat-label { + font-size: 11px; + opacity: 0.7; +} + +.card-badges { + display: flex; + gap: 6px; +} + +.card-badge-icon { + width: 28px; + height: 28px; +} + +.card-footer { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + opacity: 0.6; + margin-top: 4px; +} + +.card-modal-actions { + display: flex; +} \ No newline at end of file diff --git a/src/components/dashboard/LeaderBoard/leaderboard.tsx b/src/components/dashboard/LeaderBoard/leaderboard.tsx index c4738056..0258936a 100644 --- a/src/components/dashboard/LeaderBoard/leaderboard.tsx +++ b/src/components/dashboard/LeaderBoard/leaderboard.tsx @@ -9,6 +9,7 @@ import PRListModal from "./PRListModal"; import BadgeModal from "./BadgeModal"; import { mockContributors } from "./mockData"; import "./leaderboard.css"; +import ContributorCard from "./ContributorCard"; const GITHUB_ORG = "recodehive"; @@ -41,7 +42,7 @@ interface Contributor { points: number; prs: number; prDetails?: PRDetails[]; - badges?: string[]; // Array of badge image paths + badges?: string[]; } interface Stats { @@ -104,28 +105,19 @@ const BADGE_CONFIG = [ }, ]; -/** - * Determines which badges a contributor should have based on their stats - */ -function getContributorBadges( - contributor: Contributor, - rank: number, -): string[] { +function getContributorBadges(contributor: Contributor, rank: number): string[] { const badges: string[] = []; - // Special rank-based badges if (rank === 1) { - badges.push("/badges/10.png"); // Hall of Fame for #1 + badges.push("/badges/10.png"); } else if (rank === 2) { - badges.push("/badges/9.png"); // Legendary for #2 + badges.push("/badges/9.png"); } else if (rank === 3) { - badges.push("/badges/8.png"); // Elite for #3 + badges.push("/badges/8.png"); } - // Achievement-based badges BADGE_CONFIG.forEach((badge) => { if (badge.criteria(contributor.prs, contributor.points)) { - // Avoid duplicates if (!badges.includes(badge.image)) { badges.push(badge.image); } @@ -135,9 +127,6 @@ function getContributorBadges( return badges; } -/** - * Badge display component - */ function ContributorBadges({ badges, onClick, @@ -270,7 +259,6 @@ function TopPerformerCard({ } export default function LeaderBoard(): JSX.Element { - // Get time filter functions from context const { contributors, stats, @@ -284,16 +272,13 @@ export default function LeaderBoard(): JSX.Element { const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); - const [selectedContributor, setSelectedContributor] = - useState(null); + const [selectedContributor, setSelectedContributor] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [badgeModalContributor, setBadgeModalContributor] = - useState(null); + const [badgeModalContributor, setBadgeModalContributor] = useState(null); const [isBadgeModalOpen, setIsBadgeModalOpen] = useState(false); const [isSelectChanged, setIsSelectChanged] = useState(false); const itemsPerPage = 20; - // Modal handlers const handlePRClick = (contributor: Contributor) => { setSelectedContributor(contributor); setIsModalOpen(true); @@ -304,21 +289,16 @@ export default function LeaderBoard(): JSX.Element { setSelectedContributor(null); }, []); - // Badge modal handlers - const handleBadgeClick = useCallback( - (contributor: Contributor) => { - setBadgeModalContributor(contributor); - setIsBadgeModalOpen(true); - }, - [], - ); + const handleBadgeClick = useCallback((contributor: Contributor) => { + setBadgeModalContributor(contributor); + setIsBadgeModalOpen(true); + }, []); const handleCloseBadgeModal = useCallback(() => { setIsBadgeModalOpen(false); setBadgeModalContributor(null); }, []); - // 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" @@ -326,7 +306,6 @@ export default function LeaderBoard(): JSX.Element { : [] : contributors; - // Filter out excluded users and apply search filter const filteredContributors = contributors .filter( (contributor) => @@ -349,9 +328,7 @@ export default function LeaderBoard(): JSX.Element { const renderPaginationButtons = () => { const pages = []; - const maxVisibleButtons = 5; // Maximum number of page buttons to show directly - // Special case: if we have 7 or fewer pages, show all of them without ellipsis if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) { pages.push( @@ -367,9 +344,6 @@ export default function LeaderBoard(): JSX.Element { return pages; } - // For more than 7 pages, use the ellipsis approach - - // Always show first page pages.push(