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
155 changes: 112 additions & 43 deletions frontend/src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { GitPullRequest, Clock } from 'lucide-react';
import { motion, useMotionValue, useTransform, PanInfo } from 'framer-motion';
import { GitPullRequest, Clock, ArrowRight } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import { cardHover } from '../../lib/animations';
import { mobileStaggerItem } from '../../lib/animations';
import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils';

function TierBadge({ tier }: { tier: string }) {
Expand All @@ -13,18 +13,30 @@ function TierBadge({ tier }: { tier: string }) {
T3: 'bg-tier-t3/10 text-tier-t3 border border-tier-t3/20',
};
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${styles[tier] ?? styles.T1}`}>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${styles[tier] ?? styles.T1}`}>
{tier}
</span>
);
}

interface BountyCardProps {
bounty: Bounty;
index?: number;
}

export function BountyCard({ bounty }: BountyCardProps) {
export function BountyCard({ bounty, index = 0 }: BountyCardProps) {
const navigate = useNavigate();
const [isPressed, setIsPressed] = useState(false);
const [showPreview, setShowPreview] = useState(false);

// Swipe gesture handling for mobile quick actions
const x = useMotionValue(0);
const opacity = useTransform(x, [-100, 0, 100], [0.5, 1, 0.5]);
const background = useTransform(
x,
[-100, 0, 100],
['rgba(0,230,118,0.1)', 'rgba(0,0,0,0)', 'rgba(224,64,251,0.1)']
);

const orgName = bounty.org_name ?? bounty.github_issue_url?.split('/')[3] ?? 'unknown';
const repoName = bounty.repo_name ?? bounty.github_issue_url?.split('/')[4] ?? 'repo';
Expand Down Expand Up @@ -55,75 +67,132 @@ export function BountyCard({ bounty }: BountyCardProps) {
cancelled: 'bg-status-error',
}[bounty.status] ?? 'bg-emerald';

const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
// Swipe threshold for quick actions
if (Math.abs(info.offset.x) > 80) {
setShowPreview(true);
setTimeout(() => setShowPreview(false), 2000);
}
};

const handleClick = () => {
navigate(`/bounties/${bounty.id}`);
};

return (
<motion.div
variants={cardHover}
initial="rest"
whileHover="hover"
onClick={() => navigate(`/bounties/${bounty.id}`)}
className="relative rounded-xl border border-border bg-forge-900 p-5 cursor-pointer transition-colors duration-200 overflow-hidden group"
<motion.article
variants={mobileStaggerItem}
initial="initial"
animate="animate"
whileTap={{ scale: 0.98 }}
style={{ x, opacity, background }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.15}
onDragEnd={handleDragEnd}
onClick={handleClick}
onPointerDown={() => setIsPressed(true)}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
className={`relative rounded-xl border border-border bg-forge-900 p-3 sm:p-4 lg:p-5 cursor-pointer transition-all duration-200 overflow-hidden group min-h-[180px] sm:min-h-[200px] flex flex-col tap-highlight ${isPressed ? 'scale-[0.98]' : ''}`}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
aria-label={`Bounty: ${bounty.title}, Reward: ${formatCurrency(bounty.reward_amount, bounty.reward_token)}, Status: ${statusLabel}`}
>
{/* Quick Preview Indicator (shown on swipe) */}
{showPreview && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="absolute top-2 right-2 z-10 px-2 py-1 bg-emerald/20 border border-emerald/30 rounded text-[10px] text-emerald font-medium"
>
Swipe for quick actions
</motion.div>
)}

{/* Row 1: Repo + Tier */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-start sm:items-center justify-between gap-2 text-sm">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
{bounty.org_avatar_url && (
<img src={bounty.org_avatar_url} className="w-5 h-5 rounded-full flex-shrink-0" alt="" />
<img
src={bounty.org_avatar_url}
className="w-4 h-4 sm:w-5 sm:h-5 rounded-full flex-shrink-0"
alt=""
loading="lazy"
/>
)}
<span className="text-text-muted font-mono text-xs truncate">
<span className="text-text-muted font-mono text-[10px] sm:text-xs truncate">
{orgName}/{repoName}
{issueNumber && <span className="ml-1">#{issueNumber}</span>}
{issueNumber && <span className="ml-0.5 sm:ml-1">#{issueNumber}</span>}
</span>
</div>
<TierBadge tier={bounty.tier ?? 'T1'} />
</div>

{/* Row 2: Title */}
<h3 className="mt-3 font-sans text-base font-semibold text-text-primary leading-snug line-clamp-2">
<h3 className="mt-2 sm:mt-3 font-sans text-sm sm:text-base font-semibold text-text-primary leading-snug line-clamp-2 flex-grow">
{bounty.title}
</h3>

{/* Row 3: Language dots */}
{skills.length > 0 && (
<div className="flex items-center gap-3 mt-3">
<div className="flex items-center gap-2 sm:gap-3 mt-2 sm:mt-3 flex-wrap">
{skills.map((lang) => (
<span key={lang} className="inline-flex items-center gap-1.5 text-xs text-text-muted">
<span key={lang} className="inline-flex items-center gap-1 sm:gap-1.5 text-[10px] sm:text-xs text-text-muted">
<span
className="w-2.5 h-2.5 rounded-full"
className="w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: LANG_COLORS[lang] ?? '#888' }}
/>
{lang}
<span className="hidden xs:inline">{lang}</span>
</span>
))}
</div>
)}

{/* Separator */}
<div className="mt-4 border-t border-border/50" />
<div className="mt-3 sm:mt-4 border-t border-border/50" />

{/* Row 4: Reward + Meta */}
<div className="flex items-center justify-between mt-3">
<span className="font-mono text-lg font-semibold text-emerald">
{/* Row 4: Reward + Meta + Status */}
<div className="flex items-center justify-between mt-2 sm:mt-3 gap-2">
<span className="font-mono text-base sm:text-lg font-semibold text-emerald truncate">
{formatCurrency(bounty.reward_amount, bounty.reward_token)}
</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="inline-flex items-center gap-1">
<GitPullRequest className="w-3.5 h-3.5" />
{bounty.submission_count} PRs

{/* Status badge - inline on mobile instead of absolute */}
<span className={`text-[10px] sm:text-xs font-medium inline-flex items-center gap-1 flex-shrink-0 ${statusColor}`}>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
<span className="hidden xs:inline">{statusLabel}</span>
<span className="xs:hidden">{statusLabel.split(' ')[0]}</span>
</span>
</div>

{/* Row 5: Meta info (PRs, Deadline) */}
<div className="flex items-center gap-2 sm:gap-3 mt-2 text-[10px] sm:text-xs text-text-muted">
<span className="inline-flex items-center gap-1">
<GitPullRequest className="w-3 h-3 sm:w-3.5 sm:h-3.5 flex-shrink-0" />
<span className="truncate">{bounty.submission_count} PRs</span>
</span>
{bounty.deadline && (
<span className="inline-flex items-center gap-1 truncate">
<Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5 flex-shrink-0" />
<span className="truncate">{timeLeft(bounty.deadline)}</span>
</span>
{bounty.deadline && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{timeLeft(bounty.deadline)}
</span>
)}
</div>
)}
</div>

{/* Status badge */}
<span className={`absolute bottom-4 right-5 text-xs font-medium inline-flex items-center gap-1 ${statusColor}`}>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
{statusLabel}
</span>
</motion.div>
{/* Mobile Quick Action Hint */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isPressed ? 1 : 0 }}
className="absolute inset-0 bg-gradient-to-r from-emerald/5 via-transparent to-magenta/5 pointer-events-none"
/>
</motion.article>
);
}
Loading
Loading