Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add graph for each repo to showcase contributors #147

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
66 changes: 66 additions & 0 deletions app/(dashboard)/enroll/[repositoryId]/graph/ContributorAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="absolute cursor-pointer overflow-hidden rounded-full border-2 border-gray-200 shadow-lg transition-transform hover:scale-105"
style={{
width: `${position.size}px`,
height: `${position.size}px`,
top: `${position.y}px`,
left: `${position.x}px`,
}}
onClick={() => handleClick(`https://github.com/${contributor.githubId}`)}
role="link"
aria-label={`View ${contributor.name}'s GitHub profile`}
>
{hasError ? (
<div className="flex h-full w-full items-center justify-center bg-gray-300 text-sm font-bold text-gray-600">
{contributor.name[0]}
</div>
) : (
<Image
src={contributor.avatarUrl}
alt={`${contributor.name}'s avatar`}
width={position.size}
height={position.size}
className="object-cover"
onError={() => setHasError(true)}
placeholder="blur"
blurDataURL="/placeholder.svg?height=100&width=100"
/>
)}
</div>
);
}
125 changes: 125 additions & 0 deletions app/(dashboard)/enroll/[repositoryId]/graph/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w-dvh max-w-screen h-dvh max-h-screen min-h-[900px] min-w-[1600px] bg-gradient-to-br from-white to-[#fb7a00]">
<div className="relative flex items-center justify-center">
{contributors.map((contributor, index) => {
const position = positions[index];
if (!position) return null;

return (
<ContributorAvatar
key={`${contributor.name}-${index}`}
contributor={contributor}
position={position}
/>
);
})}
</div>
</div>
);
}
Loading