From 01a3bb5b18e4a058a9665017f067f3cbc9fcda41 Mon Sep 17 00:00:00 2001 From: Marc Seitz Date: Fri, 1 Nov 2024 17:28:51 +0900 Subject: [PATCH] feat: add graph for each repo to showcase contributors --- .../graph/ContributorAvatar.tsx | 66 +++++++++ .../enroll/[repositoryId]/graph/page.tsx | 125 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx create mode 100644 app/(dashboard)/enroll/[repositoryId]/graph/page.tsx diff --git a/app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx b/app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx new file mode 100644 index 0000000..3317002 --- /dev/null +++ b/app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Image from "next/image"; + +import { useState } from "react"; + +interface Contributor { + avatarUrl: string; + name: string; + points: number; + githubId: string; +} + +interface Position { + x: number; + y: number; + size: number; +} + +interface ContributorAvatarProps { + contributor: Contributor; + position: Position; +} + +export function ContributorAvatar({ + contributor, + position, +}: ContributorAvatarProps) { + const [hasError, setHasError] = useState(false); + + const handleClick = (url: string) => { + window.open(url, "_blank", "noopener,noreferrer"); + }; + + return ( +
handleClick(`https://github.com/${contributor.githubId}`)} + role="link" + aria-label={`View ${contributor.name}'s GitHub profile`} + > + {hasError ? ( +
+ {contributor.name[0]} +
+ ) : ( + {`${contributor.name}'s setHasError(true)} + placeholder="blur" + blurDataURL="/placeholder.svg?height=100&width=100" + /> + )} +
+ ); +} \ No newline at end of file diff --git a/app/(dashboard)/enroll/[repositoryId]/graph/page.tsx b/app/(dashboard)/enroll/[repositoryId]/graph/page.tsx new file mode 100644 index 0000000..61882a9 --- /dev/null +++ b/app/(dashboard)/enroll/[repositoryId]/graph/page.tsx @@ -0,0 +1,125 @@ +import { ContributorAvatar } from "./ContributorAvatar"; +import {db} from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +interface Contributor { + avatarUrl: string; + name: string; + points: number; + githubId: string; +} + +interface Position { + x: number; + y: number; + size: number; +} + +async function getData(repositoryId: string) { + const pointTransactions = await db.pointTransaction.groupBy({ + by: ['userId'], + where: { + repositoryId, + }, + _sum: { + points: true, + }, + }); + + const users = await db.user.findMany({ + where: { + id: { + in: pointTransactions.map((entry) => entry.userId), + }, + }, + select: { + avatarUrl: true, + name: true, + githubId: true, + }, + }); + + return users.map((user) => ({ + avatarUrl: user.avatarUrl || '', + name: user.name || '', + points: pointTransactions.find((entry) => entry.userId === user.id)?._sum.points || 0, + githubId: user.githubId.toString(), + })).filter((c) => c.avatarUrl && c.name && c.points > 0); +} + +export default async function Component() { + const contributors = await getData(); + + // Move these calculations to server + const maxPoints = Math.max(...contributors.map((c) => c.points)); + + const minSize = 40; + const maxSize = 180; + + const checkCollision = (positions: Position[], newPos: Position): boolean => { + for (const pos of positions) { + const dx = newPos.x - pos.x; + const dy = newPos.y - pos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < (newPos.size + pos.size) / 2) { + return true; + } + } + return false; + }; + + const generatePositions = (contributors: Contributor[]): Position[] => { + const positions: Position[] = []; + const containerWidth = 1600; + const containerHeight = 900; + const maxAttempts = 100; + + for (const contributor of contributors) { + const size = + minSize + + Math.sqrt(contributor.points / maxPoints) * (maxSize - minSize); + let newPos: Position | null = null; + let attempts = 0; + + while (!newPos && attempts < maxAttempts) { + const x = Math.random() * (containerWidth - size); + const y = Math.random() * (containerHeight - size); + const testPos = { x, y, size }; + + if (!checkCollision(positions, testPos)) { + newPos = testPos; + } + + attempts++; + } + + if (newPos) { + positions.push(newPos); + } + } + + return positions; + }; + + const positions = generatePositions(contributors); + + return ( +
+
+ {contributors.map((contributor, index) => { + const position = positions[index]; + if (!position) return null; + + return ( + + ); + })} +
+
+ ); +} \ No newline at end of file