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
7 changes: 7 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{ protocol: "https", hostname: "github.com" },
{ protocol: "https", hostname: "avatars.githubusercontent.com" },
{ protocol: "https", hostname: "ui-avatars.com" },
],
},
};

export default nextConfig;
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
Expand Down
67 changes: 51 additions & 16 deletions src/components/bounty-card.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,85 @@
type BountyCardProps = {
import clsx from "clsx";

export type BountyCardProps = {
title: string;
reward: number;
tags: string[];
difficulty: "Easy" | "Medium" | "Hard";
progress: number;
};

const difficultyStyles = {
const difficultyStyles: Record<BountyCardProps["difficulty"], string> = {
Easy: "bg-emerald-50 text-emerald-700 border-emerald-200",
Medium: "bg-amber-50 text-amber-700 border-amber-200",
Hard: "bg-rose-50 text-rose-700 border-rose-200",
};

const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});

const clampProgress = (value: number) => Math.min(100, Math.max(0, value));

export function BountyCard({ title, reward, tags, difficulty, progress }: BountyCardProps) {
const safeProgress = clampProgress(progress);

return (
<div className="card p-4 sm:p-5 hover:shadow-md transition">
<div className="flex items-start justify-between gap-3">
<article
className="group card relative overflow-hidden p-4 sm:p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-xl"
aria-label={`Bounty: ${title}`}
>
<div className="pointer-events-none absolute inset-0 opacity-0 bg-gradient-to-br from-brand-50/80 via-transparent to-transparent transition duration-200 group-hover:opacity-100" />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-base sm:text-lg font-semibold leading-snug break-words">{title}</h3>
<div className="mt-1.5 flex flex-wrap gap-1.5">
<p className="text-[11px] font-semibold uppercase tracking-[0.25em] text-brand-600">Featured</p>
<h3 className="mt-1 text-lg font-semibold leading-snug text-slate-900 dark:text-white">
{title}
</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span key={tag} className="pill text-[11px] sm:text-xs px-2 py-0.5 sm:px-3 sm:py-1">
<span key={tag} className="pill text-[11px] sm:text-xs font-medium">
{tag}
</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xl sm:text-xl font-bold">${reward}</div>
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs font-semibold whitespace-nowrap ${difficultyStyles[difficulty]}`}>
<div className="shrink-0 text-right">
<p className="text-[11px] uppercase tracking-[0.25em] text-slate-500">Reward</p>
<p className="text-2xl font-black text-slate-900 dark:text-white">
{formatter.format(reward)}
</p>
<span
className={clsx(
"mt-2 inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold",
difficultyStyles[difficulty]
)}
>
{difficulty}
</span>
</div>
</div>
<div className="mt-3">

<div
className="mt-4"
role="progressbar"
aria-valuenow={safeProgress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Progress"
>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Progress</span>
<span>{progress}%</span>
<span>{safeProgress}%</span>
</div>
<div className="mt-1.5 h-2 w-full rounded-full bg-slate-100">
<div className="mt-1.5 h-2.5 w-full rounded-full bg-slate-100 dark:bg-slate-700">
<div
className="h-2 rounded-full bg-brand-600"
style={{ width: `${progress}%` }}
className="h-full rounded-full bg-gradient-to-r from-brand-500 to-brand-700"
style={{ width: `${safeProgress}%` }}
/>
</div>
</div>
</div>
</article>
);
}
23 changes: 15 additions & 8 deletions src/components/leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import Image from "next/image";

// BUG: Sorting algorithm doesn't handle ties correctly
// When two users have the same earnings, their relative order is inconsistent
// FIX: Add secondary sort key (e.g., by name or join date)
Expand Down Expand Up @@ -43,14 +45,19 @@ export function Leaderboard() {
<span className="w-8 h-8 flex items-center justify-center rounded-full bg-slate-200 text-sm font-bold">
{index + 1}
</span>
<img
src={entry.avatar}
alt={entry.name}
className="w-10 h-10 rounded-full"
onError={(e) => {
(e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${entry.name}`;
}}
/>
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-slate-200">
<Image
src={entry.avatar}
alt={entry.name}
fill
sizes="40px"
className="object-cover"
onError={(event) => {
const target = event.currentTarget;
target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(entry.name)}`;
}}
/>
</div>
<div className="flex-1">
<p className="font-medium">{entry.name}</p>
<p className="text-xs text-slate-500">
Expand Down