mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-11 23:19:24 +00:00
Initial commit.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, provide } from 'vue'
|
||||
import Navbar from './components/Navbar.vue'
|
||||
import Footer from './components/Footer.vue'
|
||||
|
||||
const globalData = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
provide('globalData', globalData)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('./data/global.json')
|
||||
if (!response.ok) throw new Error('Failed to load data')
|
||||
globalData.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
|
||||
<main class="flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-view v-else />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
achievementId: { type: String, required: true },
|
||||
size: { type: String, default: 'md' }, // sm, md, lg
|
||||
showLabel: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
// Tier colors based on threshold (1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000+)
|
||||
const tierGradients = {
|
||||
1: 'from-stone-400 to-stone-500', // Bronze - tier 1
|
||||
2: 'from-green-400 to-emerald-500', // Green - tier 10
|
||||
3: 'from-blue-400 to-indigo-500', // Blue - tier 25
|
||||
4: 'from-purple-400 to-violet-500', // Purple - tier 50
|
||||
5: 'from-yellow-400 to-amber-500', // Gold - tier 100
|
||||
6: 'from-orange-400 to-red-500', // Orange - tier 250
|
||||
7: 'from-red-500 to-rose-600', // Red - tier 500
|
||||
8: 'from-pink-500 to-fuchsia-600', // Pink - tier 1000
|
||||
9: 'from-cyan-400 to-teal-500', // Cyan - tier 5000
|
||||
10: 'from-emerald-400 to-cyan-500', // Emerald - tier 10000
|
||||
11: 'from-violet-500 to-purple-600', // Legendary - tier 25000+
|
||||
}
|
||||
|
||||
// Get tier from threshold number
|
||||
const getTierFromThreshold = (threshold) => {
|
||||
const tiers = [1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000]
|
||||
for (let i = tiers.length - 1; i >= 0; i--) {
|
||||
if (threshold >= tiers[i]) return i + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Extract threshold from achievement ID (e.g., "commit-100" -> 100)
|
||||
const extractThreshold = (id) => {
|
||||
const match = id.match(/(\d+)$/)
|
||||
if (match) return parseInt(match[1], 10)
|
||||
// Special cases for non-numeric achievements
|
||||
if (id === 'first-commit' || id === 'pr-opener' || id === 'reviewer') return 1
|
||||
return 50 // Default for special achievements
|
||||
}
|
||||
|
||||
// Achievement definitions matching the Go backend
|
||||
const achievements = {
|
||||
// Commit achievements - Journey from apprentice to legend
|
||||
'first-commit': { name: 'Hello World', description: 'Made your first commit', icon: 'fa-baby' },
|
||||
'commit-10': { name: 'Seedling', description: 'Made 10 commits', icon: 'fa-seedling' },
|
||||
'commit-25': { name: 'Momentum', description: 'Made 25 commits', icon: 'fa-wind' },
|
||||
'commit-50': { name: 'Trailblazer', description: 'Made 50 commits', icon: 'fa-hiking' },
|
||||
'commit-100': { name: 'Centurion', description: 'Made 100 commits', icon: 'fa-shield-halved' },
|
||||
'commit-250': { name: 'Relentless', description: 'Made 250 commits', icon: 'fa-bolt-lightning' },
|
||||
'commit-500': { name: 'Unstoppable', description: 'Made 500 commits', icon: 'fa-meteor' },
|
||||
'commit-1000': { name: 'Grandmaster', description: 'Made 1000 commits', icon: 'fa-chess-king' },
|
||||
'commit-5000': { name: 'Titan', description: 'Made 5000 commits', icon: 'fa-mountain-sun' },
|
||||
'commit-10000': { name: 'Immortal', description: 'Made 10000 commits', icon: 'fa-dragon' },
|
||||
'commit-25000': { name: 'Ascended', description: 'Made 25000 commits', icon: 'fa-infinity' },
|
||||
|
||||
// PR achievements - The art of collaboration
|
||||
'pr-opener': { name: 'First Blood', description: 'Opened your first pull request', icon: 'fa-flag-checkered' },
|
||||
'pr-10': { name: 'Collaborator', description: 'Opened 10 pull requests', icon: 'fa-handshake' },
|
||||
'pr-25': { name: 'Integrator', description: 'Opened 25 pull requests', icon: 'fa-code-branch' },
|
||||
'pr-50': { name: 'Architect', description: 'Opened 50 pull requests', icon: 'fa-building' },
|
||||
'pr-100': { name: 'Vanguard', description: 'Opened 100 pull requests', icon: 'fa-rocket' },
|
||||
|
||||
// Review achievements - The guardian path
|
||||
'reviewer': { name: 'Watchful Eye', description: 'Reviewed your first pull request', icon: 'fa-eye' },
|
||||
'reviewer-10': { name: 'Sentinel', description: 'Reviewed 10 pull requests', icon: 'fa-shield' },
|
||||
'reviewer-25': { name: 'Gatekeeper', description: 'Reviewed 25 pull requests', icon: 'fa-dungeon' },
|
||||
'reviewer-50': { name: 'Oracle', description: 'Reviewed 50 pull requests', icon: 'fa-hat-wizard' },
|
||||
'reviewer-100': { name: 'Sage', description: 'Reviewed 100 pull requests', icon: 'fa-book-skull' },
|
||||
|
||||
// Speed achievements - Time is of the essence
|
||||
'speed-demon': { name: 'Lightning Rod', description: 'Average review response under 1 hour', icon: 'fa-bolt' },
|
||||
'quick-responder': { name: 'Flash', description: 'Average review response under 4 hours', icon: 'fa-gauge-high' },
|
||||
|
||||
// Comment achievements
|
||||
'commentator': { name: 'Wordsmith', description: 'Left 50 PR review comments', icon: 'fa-feather-pointed' },
|
||||
|
||||
// Lines of code achievements - Volume mastery
|
||||
'lines-1000': { name: 'Scribe', description: 'Added 1000 lines of code', icon: 'fa-scroll' },
|
||||
'lines-10000': { name: 'Novelist', description: 'Added 10000 lines of code', icon: 'fa-book' },
|
||||
'lines-100000': { name: 'Encyclopedia', description: 'Added 100000 lines of code', icon: 'fa-landmark' },
|
||||
|
||||
// Deletion achievements - The minimalist way
|
||||
'cleaner': { name: 'Pruner', description: 'Deleted 1000 lines of code', icon: 'fa-scissors' },
|
||||
'refactorer': { name: 'Surgeon', description: 'Deleted 10000 lines of code', icon: 'fa-syringe' },
|
||||
'annihilator': { name: 'Annihilator', description: 'Deleted 100000 lines of code', icon: 'fa-explosion' },
|
||||
|
||||
// Multi-repo achievements - The wanderer
|
||||
'multi-repo': { name: 'Nomad', description: 'Contributed to 5 repositories', icon: 'fa-compass' },
|
||||
'multi-repo-10': { name: 'Explorer', description: 'Contributed to 10 repositories', icon: 'fa-map' },
|
||||
|
||||
// Team collaboration - Social butterfly
|
||||
'team-player': { name: 'Ambassador', description: 'Reviewed PRs from 10 different contributors', icon: 'fa-users' },
|
||||
'team-player-25': { name: 'Diplomat', description: 'Reviewed PRs from 25 different contributors', icon: 'fa-globe' },
|
||||
|
||||
// PR size achievements - Go big or go home
|
||||
'big-pr': { name: 'Heavyweight', description: 'Merged a PR with 1000+ lines', icon: 'fa-dumbbell' },
|
||||
'mega-pr': { name: 'Colossus', description: 'Merged a PR with 5000+ lines', icon: 'fa-monument' },
|
||||
|
||||
// Small PR achievements - Precision strikes
|
||||
'small-pr-10': { name: 'Minimalist', description: 'Merged 10 PRs under 100 lines', icon: 'fa-compress' },
|
||||
'small-pr-50': { name: 'Atomic', description: 'Merged 50 PRs under 100 lines', icon: 'fa-atom' },
|
||||
|
||||
// Perfect PR achievements - Flawless execution
|
||||
'perfect-pr-5': { name: 'Sharpshooter', description: '5 PRs merged without changes requested', icon: 'fa-bullseye' },
|
||||
'perfect-pr-25': { name: 'Perfectionist', description: '25 PRs merged without changes requested', icon: 'fa-gem' },
|
||||
'perfect-pr-100': { name: 'Immaculate', description: '100 PRs merged without changes requested', icon: 'fa-crown' },
|
||||
|
||||
// Streak achievements - Consistency is key
|
||||
'streak-7': { name: 'Hot Streak', description: '7 day contribution streak', icon: 'fa-fire' },
|
||||
'streak-30': { name: 'Ironclad', description: '30 day contribution streak', icon: 'fa-link' },
|
||||
'streak-90': { name: 'Unbreakable', description: '90 day contribution streak', icon: 'fa-diamond' },
|
||||
|
||||
// Time-based achievements - When you code matters
|
||||
'early-bird': { name: 'Dawn Patrol', description: '50 commits before 9am', icon: 'fa-sun' },
|
||||
'night-owl': { name: 'Nighthawk', description: '50 commits after 9pm', icon: 'fa-moon' },
|
||||
'nosferatu': { name: 'Vampire', description: '25 commits between midnight and 4am', icon: 'fa-ghost' },
|
||||
'weekend-warrior': { name: 'No Days Off', description: '25 weekend commits', icon: 'fa-calendar-xmark' },
|
||||
|
||||
// Activity achievements - Showing up matters
|
||||
'active-30': { name: 'Reliable', description: 'Active on 30 different days', icon: 'fa-calendar-check' },
|
||||
'active-100': { name: 'Stalwart', description: 'Active on 100 different days', icon: 'fa-tower-observation' },
|
||||
'active-365': { name: 'Eternal', description: 'Active on 365 different days', icon: 'fa-sun-plant-wilt' }
|
||||
}
|
||||
|
||||
const getAchievement = (id) => {
|
||||
const base = achievements[id] || { name: id, description: '', icon: 'fa-medal' }
|
||||
const threshold = extractThreshold(id)
|
||||
const tier = getTierFromThreshold(threshold)
|
||||
const gradient = tierGradients[tier] || 'from-gray-400 to-gray-500'
|
||||
return { ...base, gradient, tier, threshold }
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { wrapper: 'w-9 h-9', icon: 'text-sm', radius: 'rounded-lg' },
|
||||
md: { wrapper: 'w-11 h-11', icon: 'text-base', radius: 'rounded-xl' },
|
||||
lg: { wrapper: 'w-14 h-14', icon: 'text-lg', radius: 'rounded-xl' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex flex-col items-center gap-2">
|
||||
<!-- Badge -->
|
||||
<div
|
||||
class="relative group/badge"
|
||||
:title="getAchievement(achievementId).name"
|
||||
>
|
||||
<!-- Badge square with rounded corners -->
|
||||
<div
|
||||
class="flex items-center justify-center bg-gradient-to-br shadow-lg hover:scale-105 hover:shadow-xl transition-all duration-200 cursor-pointer"
|
||||
:class="[
|
||||
sizeClasses[size].wrapper,
|
||||
sizeClasses[size].radius,
|
||||
getAchievement(achievementId).gradient
|
||||
]"
|
||||
>
|
||||
<i
|
||||
class="fas text-white drop-shadow-sm"
|
||||
:class="[getAchievement(achievementId).icon, sizeClasses[size].icon]"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-xl opacity-0 group-hover/badge:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50 shadow-xl border border-white/10">
|
||||
<div class="font-bold text-sm">{{ getAchievement(achievementId).name }}</div>
|
||||
<div class="text-gray-300 text-[11px] mt-0.5">{{ getAchievement(achievementId).description }}</div>
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-gray-900 dark:border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label (optional) - no truncation -->
|
||||
<span
|
||||
v-if="showLabel"
|
||||
class="text-[11px] font-medium text-gray-600 dark:text-gray-400 text-center leading-tight"
|
||||
style="max-width: 72px; word-wrap: break-word;"
|
||||
>
|
||||
{{ getAchievement(achievementId).name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,335 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
const props = defineProps({
|
||||
contributor: { type: Object, required: true },
|
||||
showEarned: { type: Boolean, default: false },
|
||||
maxDisplay: { type: Number, default: 6 }
|
||||
})
|
||||
|
||||
// Achievement tier thresholds
|
||||
const tiers = [1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000]
|
||||
|
||||
// Tier gradient colors
|
||||
const tierGradients = {
|
||||
1: 'from-stone-400 to-stone-500',
|
||||
2: 'from-green-400 to-emerald-500',
|
||||
3: 'from-blue-400 to-indigo-500',
|
||||
4: 'from-purple-400 to-violet-500',
|
||||
5: 'from-yellow-400 to-amber-500',
|
||||
6: 'from-orange-400 to-red-500',
|
||||
7: 'from-red-500 to-rose-600',
|
||||
8: 'from-pink-500 to-fuchsia-600',
|
||||
9: 'from-cyan-400 to-teal-500',
|
||||
10: 'from-emerald-400 to-cyan-500',
|
||||
11: 'from-violet-500 to-purple-600',
|
||||
}
|
||||
|
||||
// Progress bar colors based on tier
|
||||
const tierProgressColors = {
|
||||
1: 'bg-stone-500',
|
||||
2: 'bg-green-500',
|
||||
3: 'bg-blue-500',
|
||||
4: 'bg-purple-500',
|
||||
5: 'bg-yellow-500',
|
||||
6: 'bg-orange-500',
|
||||
7: 'bg-red-500',
|
||||
8: 'bg-pink-500',
|
||||
9: 'bg-cyan-500',
|
||||
10: 'bg-emerald-500',
|
||||
11: 'bg-violet-500',
|
||||
}
|
||||
|
||||
// Achievement definitions with progress tracking
|
||||
const achievementTypes = [
|
||||
{
|
||||
category: 'Commits',
|
||||
icon: 'fa-code-commit',
|
||||
iconColor: 'text-green-500',
|
||||
getValue: (c) => c.commit_count || 0,
|
||||
achievements: [
|
||||
{ id: 'first-commit', threshold: 1, name: 'First Steps' },
|
||||
{ id: 'commit-10', threshold: 10, name: 'Getting Started' },
|
||||
{ id: 'commit-25', threshold: 25, name: 'Warming Up' },
|
||||
{ id: 'commit-50', threshold: 50, name: 'On A Roll' },
|
||||
{ id: 'commit-100', threshold: 100, name: 'Committed' },
|
||||
{ id: 'commit-250', threshold: 250, name: 'Dedicated' },
|
||||
{ id: 'commit-500', threshold: 500, name: 'Code Machine' },
|
||||
{ id: 'commit-1000', threshold: 1000, name: 'Code Warrior' },
|
||||
{ id: 'commit-5000', threshold: 5000, name: 'Legendary' },
|
||||
{ id: 'commit-10000', threshold: 10000, name: 'Mythical' },
|
||||
{ id: 'commit-25000', threshold: 25000, name: 'Transcendent' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Pull Requests',
|
||||
icon: 'fa-code-pull-request',
|
||||
iconColor: 'text-blue-500',
|
||||
getValue: (c) => c.prs_opened || 0,
|
||||
achievements: [
|
||||
{ id: 'pr-opener', threshold: 1, name: 'PR Pioneer' },
|
||||
{ id: 'pr-10', threshold: 10, name: 'Pull Request Pro' },
|
||||
{ id: 'pr-25', threshold: 25, name: 'PR Regular' },
|
||||
{ id: 'pr-50', threshold: 50, name: 'Merge Master' },
|
||||
{ id: 'pr-100', threshold: 100, name: 'PR Champion' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Reviews',
|
||||
icon: 'fa-eye',
|
||||
iconColor: 'text-purple-500',
|
||||
getValue: (c) => c.reviews_given || 0,
|
||||
achievements: [
|
||||
{ id: 'reviewer', threshold: 1, name: 'Code Reviewer' },
|
||||
{ id: 'reviewer-10', threshold: 10, name: 'Review Starter' },
|
||||
{ id: 'reviewer-25', threshold: 25, name: 'Review Regular' },
|
||||
{ id: 'reviewer-50', threshold: 50, name: 'Review Expert' },
|
||||
{ id: 'reviewer-100', threshold: 100, name: 'Review Guru' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Lines Added',
|
||||
icon: 'fa-plus',
|
||||
iconColor: 'text-emerald-500',
|
||||
getValue: (c) => c.lines_added || 0,
|
||||
achievements: [
|
||||
{ id: 'lines-1000', threshold: 1000, name: 'Thousand Lines' },
|
||||
{ id: 'lines-10000', threshold: 10000, name: 'Ten Thousand' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Lines Deleted',
|
||||
icon: 'fa-minus',
|
||||
iconColor: 'text-red-500',
|
||||
getValue: (c) => c.lines_deleted || 0,
|
||||
achievements: [
|
||||
{ id: 'cleaner', threshold: 1000, name: 'Code Cleaner' },
|
||||
{ id: 'refactorer', threshold: 10000, name: 'Refactoring Champion' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Small PRs',
|
||||
icon: 'fa-compress',
|
||||
iconColor: 'text-cyan-500',
|
||||
getValue: (c) => c.small_pr_count || 0,
|
||||
achievements: [
|
||||
{ id: 'small-pr-10', threshold: 10, name: 'Small PR Advocate' },
|
||||
{ id: 'small-pr-50', threshold: 50, name: 'Atomic Commits Hero' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Perfect PRs',
|
||||
icon: 'fa-gem',
|
||||
iconColor: 'text-pink-500',
|
||||
getValue: (c) => c.perfect_prs || 0,
|
||||
achievements: [
|
||||
{ id: 'perfect-pr-5', threshold: 5, name: 'Clean Code' },
|
||||
{ id: 'perfect-pr-25', threshold: 25, name: 'Flawless' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Active Days',
|
||||
icon: 'fa-calendar-check',
|
||||
iconColor: 'text-orange-500',
|
||||
getValue: (c) => c.active_days || 0,
|
||||
achievements: [
|
||||
{ id: 'active-30', threshold: 30, name: 'Consistent Contributor' },
|
||||
{ id: 'active-100', threshold: 100, name: 'Dedicated Developer' },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Streak',
|
||||
icon: 'fa-fire',
|
||||
iconColor: 'text-amber-500',
|
||||
getValue: (c) => c.longest_streak || 0,
|
||||
achievements: [
|
||||
{ id: 'streak-7', threshold: 7, name: 'Week Warrior' },
|
||||
{ id: 'streak-30', threshold: 30, name: 'Month Master' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
// Get tier number from threshold
|
||||
const getTier = (threshold) => {
|
||||
for (let i = tiers.length - 1; i >= 0; i--) {
|
||||
if (threshold >= tiers[i]) return i + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Find all tiers for a category to show progression
|
||||
const getTiersForCategory = (achievements) => {
|
||||
return achievements.map(a => ({
|
||||
threshold: a.threshold,
|
||||
name: a.name,
|
||||
tier: getTier(a.threshold)
|
||||
}))
|
||||
}
|
||||
|
||||
// Calculate progress for each achievement type
|
||||
const progressItems = computed(() => {
|
||||
const earnedSet = new Set(props.contributor.achievements || [])
|
||||
const results = []
|
||||
|
||||
for (const type of achievementTypes) {
|
||||
const currentValue = type.getValue(props.contributor)
|
||||
|
||||
// Find the FIRST achievement where currentValue < threshold (true next target)
|
||||
// Also track all earned achievements
|
||||
let targetAchievement = null
|
||||
let lastEarned = null
|
||||
const allTiers = getTiersForCategory(type.achievements)
|
||||
|
||||
for (const ach of type.achievements) {
|
||||
if (currentValue >= ach.threshold) {
|
||||
// User has reached this threshold (should be earned)
|
||||
lastEarned = ach
|
||||
} else if (!targetAchievement) {
|
||||
// First achievement they haven't reached yet
|
||||
targetAchievement = ach
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no target (all thresholds exceeded)
|
||||
if (!targetAchievement) continue
|
||||
|
||||
// Calculate progress from last threshold to next
|
||||
const previousThreshold = lastEarned ? lastEarned.threshold : 0
|
||||
const progressRange = targetAchievement.threshold - previousThreshold
|
||||
const currentProgress = currentValue - previousThreshold
|
||||
const progress = Math.min(100, Math.max(0, Math.round((currentProgress / progressRange) * 100)))
|
||||
const tier = getTier(targetAchievement.threshold)
|
||||
|
||||
// Find current tier position and total tiers
|
||||
const currentTierIndex = allTiers.findIndex(t => t.threshold === targetAchievement.threshold)
|
||||
const totalTiers = allTiers.length
|
||||
|
||||
results.push({
|
||||
category: type.category,
|
||||
icon: type.icon,
|
||||
iconColor: type.iconColor,
|
||||
currentValue,
|
||||
target: targetAchievement.threshold,
|
||||
name: targetAchievement.name,
|
||||
id: targetAchievement.id,
|
||||
progress,
|
||||
tier,
|
||||
tierIndex: currentTierIndex + 1,
|
||||
totalTiers,
|
||||
allTiers,
|
||||
gradient: tierGradients[tier],
|
||||
progressColor: tierProgressColors[tier],
|
||||
isClose: progress >= 75,
|
||||
remaining: targetAchievement.threshold - currentValue,
|
||||
isEarned: earnedSet.has(targetAchievement.id),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by progress (closest to completion first)
|
||||
results.sort((a, b) => b.progress - a.progress)
|
||||
|
||||
return results.slice(0, props.maxDisplay)
|
||||
})
|
||||
|
||||
// Get count of remaining achievements (all unearned across all types)
|
||||
const remainingCount = computed(() => {
|
||||
const earnedSet = new Set(props.contributor.achievements || [])
|
||||
let totalUnearned = 0
|
||||
|
||||
for (const type of achievementTypes) {
|
||||
const currentValue = type.getValue(props.contributor)
|
||||
for (const ach of type.achievements) {
|
||||
// Count achievements where user hasn't reached the threshold
|
||||
if (currentValue < ach.threshold) {
|
||||
totalUnearned++
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.max(0, totalUnearned - props.maxDisplay)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="item in progressItems"
|
||||
:key="item.id"
|
||||
class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-gradient-to-br flex items-center justify-center shadow-md"
|
||||
:class="item.gradient"
|
||||
>
|
||||
<i class="fas text-white text-sm" :class="item.icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-white">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ item.category }}</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">•</span>
|
||||
<span class="font-medium">Tier {{ item.tierIndex }}/{{ item.totalTiers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold" :class="item.isClose ? 'text-green-500' : 'text-gray-700 dark:text-gray-200'">
|
||||
{{ formatNumber(item.currentValue) }}
|
||||
<span class="text-gray-400 dark:text-gray-500 font-normal">/</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 font-medium">{{ formatNumber(item.target) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ item.remaining > 0 ? `${formatNumber(item.remaining)} to go` : 'Ready to claim!' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="item.progressColor"
|
||||
:style="{ width: `${item.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Progress percentage and tier markers -->
|
||||
<div class="flex items-center justify-between mt-1.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span
|
||||
v-for="(t, idx) in item.allTiers.slice(0, 5)"
|
||||
:key="t.threshold"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="idx < item.tierIndex ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"
|
||||
:title="`Tier ${idx + 1}: ${t.name} (${formatNumber(t.threshold)})`"
|
||||
></span>
|
||||
<span v-if="item.totalTiers > 5" class="text-[10px] text-gray-400">+{{ item.totalTiers - 5 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-semibold"
|
||||
:class="item.isClose ? 'text-green-500' : 'text-gray-400 dark:text-gray-500'"
|
||||
>
|
||||
{{ item.progress }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show more indicator -->
|
||||
<div v-if="remainingCount > 0" class="text-center text-xs text-gray-500 dark:text-gray-400 pt-2">
|
||||
+{{ remainingCount }} more achievements to unlock
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!progressItems.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div class="w-16 h-16 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-500 flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-trophy text-2xl text-white"></i>
|
||||
</div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">All achievements unlocked!</p>
|
||||
<p class="text-sm mt-1">You're a legend!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: { type: String, default: '' },
|
||||
name: { type: String, required: true },
|
||||
size: { type: String, default: 'md' }
|
||||
})
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-14 h-14 text-xl',
|
||||
xl: 'w-16 h-16 text-2xl',
|
||||
'2xl': 'w-32 h-32 text-4xl'
|
||||
}
|
||||
|
||||
const initial = computed(() => props.name.charAt(0).toUpperCase())
|
||||
const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
:class="classes"
|
||||
class="rounded-full"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="classes"
|
||||
class="rounded-full bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
{{ initial }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
// Each item: { label: string, to?: string }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<RouterLink
|
||||
v-if="item.to"
|
||||
:to="item.to"
|
||||
class="hover:text-primary-500"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-else
|
||||
:class="index === items.length - 1 ? 'text-gray-800 dark:text-white' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<i
|
||||
v-if="index < items.length - 1"
|
||||
class="fas fa-chevron-right text-xs"
|
||||
></i>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Avatar from './Avatar.vue'
|
||||
import RankBadge from './RankBadge.vue'
|
||||
import AchievementBadge from './AchievementBadge.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
contributor: { type: Object, required: true },
|
||||
rank: { type: Number, default: 0 },
|
||||
showRank: { type: Boolean, default: true },
|
||||
featured: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'contributor', params: { login: contributor.login } }"
|
||||
:class="[
|
||||
'card animate-fade-in-up block cursor-pointer hover:shadow-lg transition-shadow',
|
||||
featured && rank === 1 ? 'ring-2 ring-yellow-400' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
:size="featured ? 'xl' : 'lg'"
|
||||
/>
|
||||
<RankBadge
|
||||
v-if="showRank && rank > 0"
|
||||
:rank="rank"
|
||||
size="sm"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
class="hover:text-primary-500 transition-colors"
|
||||
@click.stop.prevent="window.open(`https://github.com/${contributor.login}`, '_blank')"
|
||||
>
|
||||
@{{ contributor.login }}
|
||||
<i class="fas fa-external-link-alt text-xs ml-0.5 opacity-50"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="contributor.team" class="text-xs text-accent-500">{{ contributor.team }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold gradient-text">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">points</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contributor.achievements?.length" class="mt-4 flex flex-wrap gap-1.5">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements.slice(0, 6)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="contributor.achievements.length > 6"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ contributor.achievements.length - 6 }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
contributor: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showGithubLink: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => ['commits', 'prs', 'reviews', 'lines', 'score']
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'contributor', params: { login: contributor.login } }"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
class="ring-2 ring-transparent group-hover:ring-primary-500 transition-all"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<a
|
||||
v-if="showGithubLink"
|
||||
:href="`https://github.com/${contributor.login}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-primary-500 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
@{{ contributor.login }}
|
||||
<i class="fas fa-external-link-alt text-xs ml-1 opacity-50"></i>
|
||||
</a>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
@{{ contributor.login }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
// Each column: { key: string, label: string, align?: 'left'|'center'|'right', class?: string, headerClass?: string }
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
emptyIcon: {
|
||||
type: String,
|
||||
default: 'fas fa-inbox'
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: 'No data found'
|
||||
},
|
||||
rowClass: {
|
||||
type: String,
|
||||
default: 'hover:bg-gray-50 dark:hover:bg-gray-800/30 transition'
|
||||
},
|
||||
clickableRows: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['row-click'])
|
||||
|
||||
const getAlignClass = (align) => {
|
||||
switch (align) {
|
||||
case 'center': return 'text-center'
|
||||
case 'right': return 'text-right'
|
||||
default: return 'text-left'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
'px-6 py-4 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider',
|
||||
getAlignClass(col.align),
|
||||
col.headerClass
|
||||
]"
|
||||
>
|
||||
{{ col.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id || item.login || index"
|
||||
:class="[rowClass, { 'cursor-pointer': clickableRows }]"
|
||||
@click="clickableRows && $emit('row-click', item)"
|
||||
>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="['px-6 py-4', getAlignClass(col.align), col.class]"
|
||||
>
|
||||
<slot :name="col.key" :item="item" :index="index">
|
||||
{{ item[col.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!items.length" class="text-center py-12">
|
||||
<i :class="emptyIcon" class="text-4xl text-gray-300 dark:text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ emptyMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: 'An error occurred'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'fas fa-exclamation-triangle'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i :class="icon" class="text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
|
||||
const globalData = inject('globalData')
|
||||
|
||||
const generatedAt = computed(() => {
|
||||
if (!globalData.value?.GeneratedAt) return ''
|
||||
return new Date(globalData.value.GeneratedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="py-8 px-4 mt-16 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="container mx-auto text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
Generated by
|
||||
<a
|
||||
href="https://github.com/lukaszraczylo/git-velocity"
|
||||
class="text-primary-500 hover:text-primary-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Git Velocity
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="generatedAt" class="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||
{{ generatedAt }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-500 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
<slot>{{ label }}</slot>
|
||||
<i v-if="showIcon" class="fas fa-external-link-alt text-xs ml-1 opacity-50"></i>
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Loading...'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Avatar from './Avatar.vue'
|
||||
import AchievementBadge from './AchievementBadge.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
member: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
linkToProfile: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="linkToProfile ? RouterLink : 'div'"
|
||||
:to="linkToProfile ? { name: 'contributor', params: { login: member.login } } : undefined"
|
||||
class="card block"
|
||||
:class="{ 'hover:shadow-lg transition cursor-pointer': linkToProfile }"
|
||||
>
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<Avatar :src="member.avatar_url" :name="member.login" size="lg" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white">
|
||||
{{ member.name || member.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">@{{ member.login }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-center mb-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.commit_count) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.prs_opened) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.reviews_given) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Score</span>
|
||||
<span class="text-xl font-bold gradient-text">
|
||||
{{ formatNumber(member.score?.total || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="member.achievements?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<AchievementBadge
|
||||
v-for="achievement in member.achievements.slice(0, 4)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="member.achievements.length > 4"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ member.achievements.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
const repositories = computed(() => globalData.value?.Repositories || [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-50 glass shadow-modern">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex items-center space-x-2">
|
||||
<i class="fas fa-rocket text-2xl gradient-text"></i>
|
||||
<span class="text-xl font-bold gradient-text">Git Velocity</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
Leaderboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
{{ repo.Name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
class="md:hidden p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
<i class="fas fa-bars text-gray-700 dark:text-gray-200"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div v-if="mobileMenuOpen" class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col space-y-3">
|
||||
<RouterLink
|
||||
to="/"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
Leaderboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
{{ repo.Name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'text-primary-500'
|
||||
},
|
||||
breadcrumbs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
centered: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="py-12 px-4">
|
||||
<div class="container mx-auto" :class="{ 'text-center': centered }">
|
||||
<Breadcrumb v-if="breadcrumbs.length" :items="breadcrumbs" />
|
||||
|
||||
<div class="flex items-center" :class="centered ? 'justify-center' : ''">
|
||||
<slot name="prefix"></slot>
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<i v-if="icon" :class="[icon, iconColor]" class="mr-3"></i>
|
||||
<span class="gradient-text">{{ title }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="subtitle || $slots.subtitle" class="text-gray-600 dark:text-gray-300">
|
||||
<slot name="subtitle">{{ subtitle }}</slot>
|
||||
</p>
|
||||
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rank: { type: Number, required: true },
|
||||
size: { type: String, default: 'md' }
|
||||
})
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6 text-xs',
|
||||
md: 'w-8 h-8 text-sm'
|
||||
}
|
||||
|
||||
const rankClass = computed(() => {
|
||||
if (props.rank === 1) return 'rank-1'
|
||||
if (props.rank === 2) return 'rank-2'
|
||||
if (props.rank === 3) return 'rank-3'
|
||||
return 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
})
|
||||
|
||||
const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[classes, rankClass, { 'text-white': rank <= 3 }]"
|
||||
class="inline-flex items-center justify-center rounded-full font-bold"
|
||||
>
|
||||
{{ rank }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
repo: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.owner}/${repo.name}`"
|
||||
class="card hover:shadow-lg transition group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
|
||||
{{ repo.name }}
|
||||
</h3>
|
||||
<i class="fas fa-arrow-right text-gray-400 group-hover:text-primary-500 transition"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ repo.owner }}/{{ repo.name }}</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(repo.total_commits) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(repo.total_prs) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ repo.active_contributors }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Contributors</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'text-primary-500'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6">
|
||||
<i v-if="icon" :class="[icon, iconColor]" class="mr-2"></i>{{ title }}
|
||||
<slot name="suffix"></slot>
|
||||
</h2>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
value: { type: [Number, String], required: true },
|
||||
label: { type: String, required: true },
|
||||
icon: { type: String, default: '' },
|
||||
iconColor: { type: String, default: 'text-gray-500' },
|
||||
valueClass: { type: String, default: 'gradient-text' },
|
||||
delay: { type: String, default: '0s' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card animate-fade-in-up" :style="{ animationDelay: delay }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-3xl font-bold" :class="valueClass">
|
||||
{{ typeof value === 'number' ? formatNumber(value) : value }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ label }}</div>
|
||||
</div>
|
||||
<div v-if="icon" class="text-3xl opacity-50" :class="iconColor">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber, slugify } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
team: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/teams/${slugify(team.name)}`"
|
||||
class="card hover:shadow-lg transition group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member">
|
||||
<Avatar :name="member" size="sm" />
|
||||
</template>
|
||||
<span
|
||||
v-if="(team.members?.length || 0) > 5"
|
||||
class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ team.members.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold gradient-text">
|
||||
{{ formatNumber(team.total_score) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total Score</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ team.members?.length || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark)
|
||||
updateTheme()
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
isDark.value = e.matches
|
||||
updateTheme()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
updateTheme()
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
})
|
||||
|
||||
function updateTheme() {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggle"
|
||||
class="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
>
|
||||
<i v-if="isDark" class="fas fa-moon text-purple-400"></i>
|
||||
<i v-else class="fas fa-sun text-yellow-500"></i>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
timeline: {
|
||||
type: Object,
|
||||
required: true
|
||||
// Expected shape: { labels: string[], series: [{ name, color, data }] }
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
},
|
||||
showScore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const visibleSeries = computed(() => {
|
||||
if (!props.timeline?.series) return []
|
||||
// Filter out Score series unless showScore is true
|
||||
return props.timeline.series.filter(s => props.showScore || s.name !== 'Score')
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.timeline?.labels || !visibleSeries.value.length) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
labels: props.timeline.labels,
|
||||
datasets: visibleSeries.value.map(series => ({
|
||||
label: series.name,
|
||||
data: series.data,
|
||||
borderColor: series.color,
|
||||
backgroundColor: series.color + '20', // Add transparency
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
titleFont: {
|
||||
size: 14
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
},
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
return `${context.dataset.label}: ${context.parsed.y.toLocaleString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
callback: (value) => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value || !chartData.value.labels.length) return
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
|
||||
const ctx = chartRef.value.getContext('2d')
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData.value,
|
||||
options: chartOptions
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chartInstance) {
|
||||
chartInstance.data = chartData.value
|
||||
chartInstance.update()
|
||||
} else {
|
||||
createChart()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
})
|
||||
|
||||
watch(() => props.timeline, () => {
|
||||
updateChart()
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.showScore, () => {
|
||||
updateChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="velocity-chart" :style="{ height }">
|
||||
<canvas ref="chartRef"></canvas>
|
||||
<div v-if="!timeline?.labels?.length" class="flex items-center justify-center h-full">
|
||||
<p class="text-gray-400">No velocity data available</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.velocity-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Format a number with K/M suffixes for large values
|
||||
*/
|
||||
export function formatNumber(n) {
|
||||
if (n === null || n === undefined) return '0'
|
||||
if (n >= 1000000) {
|
||||
return (n / 1000000).toFixed(1) + 'M'
|
||||
}
|
||||
if (n >= 1000) {
|
||||
return (n / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return String(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hours as a human-readable duration
|
||||
*/
|
||||
export function formatDuration(hours) {
|
||||
if (hours === null || hours === undefined) return '-'
|
||||
if (hours < 1) {
|
||||
return Math.round(hours * 60) + 'm'
|
||||
}
|
||||
if (hours < 24) {
|
||||
return hours.toFixed(1) + 'h'
|
||||
}
|
||||
return (hours / 24).toFixed(1) + 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string or Date object
|
||||
*/
|
||||
export function formatDate(dateInput) {
|
||||
if (!dateInput) return ''
|
||||
const date = new Date(dateInput)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as a percentage
|
||||
*/
|
||||
export function formatPercent(value) {
|
||||
if (value === null || value === undefined) return '0%'
|
||||
return value.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to a URL-friendly slug
|
||||
*/
|
||||
export function slugify(str) {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/_/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
// Views
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Leaderboard from './views/Leaderboard.vue'
|
||||
import Repository from './views/Repository.vue'
|
||||
import Team from './views/Team.vue'
|
||||
import Contributor from './views/Contributor.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/leaderboard', name: 'leaderboard', component: Leaderboard },
|
||||
{ path: '/repos/:owner/:name', name: 'repository', component: Repository },
|
||||
{ path: '/teams/:slug', name: 'team', component: Team },
|
||||
{ path: '/contributors/:login', name: 'contributor', component: Contributor },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,78 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply bg-white/70 dark:bg-gray-900/70 backdrop-blur-md border border-white/20 dark:border-white/10;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.shadow-modern {
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark .shadow-modern {
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.score-card {
|
||||
@apply bg-gradient-to-r from-primary-400/10 to-accent-400/10 border border-primary-400/20;
|
||||
}
|
||||
|
||||
.dark .score-card {
|
||||
@apply from-primary-400/5 to-accent-400/5 border-primary-400/10;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
@apply bg-gradient-to-r from-yellow-400 to-amber-500;
|
||||
}
|
||||
|
||||
.rank-2 {
|
||||
@apply bg-gradient-to-r from-slate-400 to-slate-500;
|
||||
}
|
||||
|
||||
.rank-3 {
|
||||
@apply bg-gradient-to-r from-amber-600 to-amber-700;
|
||||
}
|
||||
|
||||
.achievement-badge {
|
||||
@apply inline-flex items-center justify-center w-10 h-10 rounded-full bg-gradient-to-r from-primary-400 to-accent-400 text-white shadow-md;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center px-6 py-3 bg-gradient-to-r from-primary-500 to-accent-500 text-white font-medium rounded-lg hover:from-primary-600 hover:to-accent-600 transition shadow-modern;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply glass rounded-xl p-6 shadow-modern;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@apply text-gray-700 dark:text-gray-200 hover:text-primary-500 dark:hover:text-primary-400 transition;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
@apply text-primary-500 font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import Avatar from '../components/Avatar.vue'
|
||||
import AchievementBadge from '../components/AchievementBadge.vue'
|
||||
import AchievementProgress from '../components/AchievementProgress.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import GithubLink from '../components/GithubLink.vue'
|
||||
import { formatNumber, formatPercent, formatDuration } from '../composables/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
const contributor = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Contributors' },
|
||||
{ label: contributor.value?.login || route.params.login }
|
||||
])
|
||||
|
||||
async function loadContributor() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const login = route.params.login
|
||||
|
||||
try {
|
||||
const response = await fetch(`./data/contributors/${login}.json`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
|
||||
const leaderboard = globalData.value?.leaderboard || []
|
||||
const leaderboardEntry = leaderboard.find(e => e.login === login)
|
||||
|
||||
if (leaderboardEntry) {
|
||||
data.score = {
|
||||
total: leaderboardEntry.score,
|
||||
rank: leaderboardEntry.rank,
|
||||
breakdown: data.score?.breakdown
|
||||
}
|
||||
data.achievements = leaderboardEntry.achievements
|
||||
}
|
||||
|
||||
contributor.value = data
|
||||
} else {
|
||||
const leaderboard = globalData.value?.leaderboard || []
|
||||
let found = leaderboard.find(e => e.login === login)
|
||||
|
||||
if (!found) {
|
||||
const repos = globalData.value?.repositories || []
|
||||
for (const repo of repos) {
|
||||
const c = repo.contributors?.find(c => c.login === login)
|
||||
if (c) {
|
||||
found = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
contributor.value = found
|
||||
} else {
|
||||
error.value = 'Contributor not found'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = `Failed to load contributor: ${e.message}`
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadContributor)
|
||||
watch(() => route.params, loadContributor)
|
||||
watch(globalData, loadContributor)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" message="Loading contributor..." />
|
||||
<ErrorState v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else-if="contributor">
|
||||
<!-- Profile Header -->
|
||||
<header class="py-12 px-4">
|
||||
<div class="container mx-auto">
|
||||
<PageHeader :breadcrumbs="breadcrumbs" :title="''" />
|
||||
|
||||
<div class="flex flex-col md:flex-row items-center md:items-start space-y-4 md:space-y-0 md:space-x-8">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
size="2xl"
|
||||
class="shadow-modern"
|
||||
/>
|
||||
|
||||
<div class="text-center md:text-left">
|
||||
<h1 class="text-4xl font-bold gradient-text">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-500 dark:text-gray-400 mt-1">
|
||||
<GithubLink :url="`https://github.com/${contributor.login}`">
|
||||
@{{ contributor.login }}
|
||||
</GithubLink>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-center md:justify-start space-x-4 mt-4">
|
||||
<div class="score-card rounded-lg px-4 py-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Score:</span>
|
||||
<span class="text-2xl font-bold gradient-text ml-2">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.score?.rank" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Rank #{{ contributor.score.rank }}
|
||||
<span v-if="contributor.score?.percentile_rank">
|
||||
(Top {{ formatPercent(contributor.score.percentile_rank) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contributor.achievements?.length" class="mt-6 flex flex-wrap justify-center md:justify-start gap-3">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="lg"
|
||||
show-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
:value="contributor.commit_count || 0"
|
||||
label="Commits"
|
||||
icon="fas fa-code-commit"
|
||||
icon-color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="contributor.prs_opened || 0"
|
||||
label="PRs Opened"
|
||||
icon="fas fa-code-pull-request"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="contributor.prs_merged || 0"
|
||||
label="PRs Merged"
|
||||
icon="fas fa-code-merge"
|
||||
icon-color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="contributor.reviews_given || 0"
|
||||
label="Reviews Given"
|
||||
icon="fas fa-eye"
|
||||
icon-color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Detailed Stats -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Code Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-code text-green-500 mr-2"></i>Code Contributions
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Lines Added</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
+{{ formatNumber(contributor.lines_added || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Lines Deleted</span>
|
||||
<span class="text-red-500 font-semibold">
|
||||
-{{ formatNumber(contributor.lines_deleted || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Files Changed</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(contributor.files_changed || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.avg_pr_size" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Avg PR Size</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(Math.round(contributor.avg_pr_size)) }} lines
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-comments text-purple-500 mr-2"></i>Review Activity
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Reviews Given</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(contributor.reviews_given || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Approvals</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
{{ formatNumber(contributor.approvals_given || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Changes Requested</span>
|
||||
<span class="text-orange-500 font-semibold">
|
||||
{{ formatNumber(contributor.changes_requested || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Review Comments</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatNumber(contributor.review_comments || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.avg_review_time_hours" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Avg Review Time</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
{{ formatDuration(contributor.avg_review_time_hours) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Score Breakdown -->
|
||||
<section v-if="contributor.score?.breakdown" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-green-500">
|
||||
{{ formatNumber(contributor.score.breakdown.commits || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Commits</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-blue-500">
|
||||
{{ formatNumber(contributor.score.breakdown.prs || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">PRs</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-purple-500">
|
||||
{{ formatNumber(contributor.score.breakdown.reviews || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Reviews</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-orange-500">
|
||||
{{ formatNumber(contributor.score.breakdown.line_changes || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Line Changes</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-yellow-500">
|
||||
{{ formatNumber(contributor.score.breakdown.response_bonus || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Response Bonus</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Achievement Progress Section -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Earned Achievements -->
|
||||
<div v-if="contributor.achievements?.length" class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
<i class="fas fa-award gradient-text mr-2"></i>Achievements Earned
|
||||
</h3>
|
||||
<span class="px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-400 to-amber-500 text-white text-sm font-bold shadow-md">
|
||||
{{ contributor.achievements.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 sm:grid-cols-5 gap-3">
|
||||
<div
|
||||
v-for="achievement in contributor.achievements"
|
||||
:key="achievement"
|
||||
class="flex flex-col items-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<AchievementBadge
|
||||
:achievement-id="achievement"
|
||||
size="md"
|
||||
show-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress to Next Achievements -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">
|
||||
<i class="fas fa-chart-line text-primary-500 mr-2"></i>Next Achievements
|
||||
</h3>
|
||||
|
||||
<AchievementProgress
|
||||
:contributor="contributor"
|
||||
:max-display="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Repositories Contributed -->
|
||||
<section v-if="contributor.repositories_contributed?.length" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader
|
||||
:title="`Contributed to ${contributor.repositories_contributed.length} Repositories`"
|
||||
icon="fas fa-folder-tree"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="repo in contributor.repositories_contributed"
|
||||
:key="repo"
|
||||
:to="`/repos/${repo}`"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<i class="fas fa-code-branch text-gray-400 mr-2"></i>
|
||||
{{ repo }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { inject, computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ContributorCard from '../components/ContributorCard.vue'
|
||||
import RepoCard from '../components/RepoCard.vue'
|
||||
import TeamCard from '../components/TeamCard.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import VelocityChart from '../components/VelocityChart.vue'
|
||||
import { formatNumber, formatDate } from '../composables/formatters'
|
||||
|
||||
const globalData = inject('globalData')
|
||||
|
||||
const metrics = computed(() => globalData.value || {})
|
||||
const leaderboard = computed(() => metrics.value.leaderboard?.slice(0, 3) || [])
|
||||
const repositories = computed(() => metrics.value.repositories || [])
|
||||
const teams = computed(() => metrics.value.teams || [])
|
||||
const velocityTimeline = computed(() => metrics.value.velocity_timeline)
|
||||
|
||||
const showScoreInChart = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<header class="py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-fade-in-up">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">
|
||||
<span class="gradient-text">Git Velocity</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
Celebrate your team's achievements and contributions with beautiful insights.
|
||||
</p>
|
||||
<!-- Period and Generation Info -->
|
||||
<div class="flex flex-col items-center space-y-2 mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<p v-if="metrics.period?.start || metrics.period?.end">
|
||||
<i class="fas fa-calendar-alt mr-1 text-primary-500"></i>
|
||||
<span class="font-medium">Period:</span>
|
||||
<span v-if="metrics.period.start">{{ formatDate(metrics.period.start) }}</span>
|
||||
<span v-if="metrics.period.start && metrics.period.end"> — </span>
|
||||
<span v-if="metrics.period.end">{{ formatDate(metrics.period.end) }}</span>
|
||||
</p>
|
||||
<p v-if="metrics.generated_at">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
Generated on {{ formatDate(metrics.generated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Velocity Timeline Chart -->
|
||||
<section v-if="velocityTimeline" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" />
|
||||
<label class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showScoreInChart"
|
||||
class="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span>Show Score</span>
|
||||
</label>
|
||||
</div>
|
||||
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="320px" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
:value="metrics.total_contributors || 0"
|
||||
label="Contributors"
|
||||
delay="0s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="metrics.total_commits || 0"
|
||||
label="Commits"
|
||||
delay="0.1s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="metrics.total_prs || 0"
|
||||
label="Pull Requests"
|
||||
delay="0.2s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="metrics.total_reviews || 0"
|
||||
label="Reviews"
|
||||
delay="0.3s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="'+' + formatNumber(metrics.total_lines_added || 0)"
|
||||
label="Lines Added"
|
||||
value-class="text-green-500"
|
||||
delay="0.4s"
|
||||
/>
|
||||
<StatCard
|
||||
:value="'-' + formatNumber(metrics.total_lines_deleted || 0)"
|
||||
label="Lines Deleted"
|
||||
value-class="text-red-500"
|
||||
delay="0.5s"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top Contributors -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Top Contributors" icon="fas fa-trophy" icon-color="text-yellow-500" />
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<ContributorCard
|
||||
v-for="(entry, index) in leaderboard"
|
||||
:key="entry.login"
|
||||
:contributor="entry"
|
||||
:rank="index + 1"
|
||||
featured
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<RouterLink to="/leaderboard" class="btn-primary">
|
||||
View Full Leaderboard
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Repositories -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Repositories" icon="fas fa-code-branch" icon-color="text-accent-500" />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<RepoCard v-for="repo in repositories" :key="`${repo.owner}/${repo.name}`" :repo="repo" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Teams -->
|
||||
<section v-if="teams.length" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Teams" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<TeamCard v-for="team in teams" :key="team.name" :team="team" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import DataTable from '../components/DataTable.vue'
|
||||
import ContributorRow from '../components/ContributorRow.vue'
|
||||
import RankBadge from '../components/RankBadge.vue'
|
||||
import AchievementBadge from '../components/AchievementBadge.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
const globalData = inject('globalData')
|
||||
const leaderboard = computed(() => globalData.value?.leaderboard || [])
|
||||
|
||||
const tableColumns = [
|
||||
{ key: 'rank', label: 'Rank', align: 'left' },
|
||||
{ key: 'contributor', label: 'Contributor', align: 'left' },
|
||||
{ key: 'achievements', label: 'Achievements', align: 'left' },
|
||||
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden md:table-cell' },
|
||||
{ key: 'category', label: 'Best At', align: 'left', headerClass: 'hidden sm:table-cell' },
|
||||
{ key: 'score', label: 'Score', align: 'right' }
|
||||
]
|
||||
|
||||
const categoryIcon = (category) => {
|
||||
const icons = {
|
||||
'Commits': 'fas fa-code-commit text-green-500',
|
||||
'PRs': 'fas fa-code-pull-request text-blue-500',
|
||||
'Reviews': 'fas fa-eye text-purple-500',
|
||||
'Comments': 'fas fa-comment text-orange-500'
|
||||
}
|
||||
return icons[category] || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Leaderboard"
|
||||
subtitle="Top contributors ranked by their velocity score"
|
||||
icon="fas fa-trophy"
|
||||
icon-color="text-yellow-500"
|
||||
centered
|
||||
/>
|
||||
|
||||
<!-- Leaderboard Table -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto max-w-5xl">
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="leaderboard"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
|
||||
>
|
||||
<template #rank="{ item }">
|
||||
<RankBadge :rank="item.rank" />
|
||||
</template>
|
||||
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" show-github-link />
|
||||
</template>
|
||||
|
||||
<template #achievements="{ item }">
|
||||
<div class="flex flex-wrap gap-1.5 max-w-[180px]">
|
||||
<AchievementBadge
|
||||
v-for="achievement in (item.achievements || []).slice(0, 6)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-if="!(item.achievements || []).length" class="text-gray-400 text-sm">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #team="{ item }">
|
||||
<td class="hidden md:table-cell">
|
||||
<span
|
||||
v-if="item.team"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
|
||||
>
|
||||
{{ item.team }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #category="{ item }">
|
||||
<td class="hidden sm:table-cell">
|
||||
<span v-if="item.top_category" class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<i :class="categoryIcon(item.top_category)" class="mr-1"></i>
|
||||
{{ item.top_category }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
{{ formatNumber(item.score) }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import DataTable from '../components/DataTable.vue'
|
||||
import ContributorRow from '../components/ContributorRow.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import GithubLink from '../components/GithubLink.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const repository = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Repositories' },
|
||||
{ label: repository.value?.name || route.params.name }
|
||||
])
|
||||
|
||||
const tableColumns = [
|
||||
{ key: 'contributor', label: 'Contributor', align: 'left' },
|
||||
{ key: 'commits', label: 'Commits', align: 'center' },
|
||||
{ key: 'prs', label: 'PRs', align: 'center' },
|
||||
{ key: 'reviews', label: 'Reviews', align: 'center' },
|
||||
{ key: 'lines', label: 'Lines +/-', align: 'center' },
|
||||
{ key: 'score', label: 'Score', align: 'right' }
|
||||
]
|
||||
|
||||
async function loadRepository() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(`./data/repos/${route.params.owner}/${route.params.name}/metrics.json`)
|
||||
if (!response.ok) throw new Error('Repository not found')
|
||||
repository.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRepository)
|
||||
watch(() => route.params, loadRepository)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" message="Loading repository..." />
|
||||
<ErrorState v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else-if="repository">
|
||||
<PageHeader
|
||||
:title="repository.name"
|
||||
icon="fas fa-code-branch"
|
||||
icon-color="text-accent-500"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
>
|
||||
<template #subtitle>
|
||||
<GithubLink :url="`https://github.com/${repository.owner}/${repository.name}`">
|
||||
{{ repository.owner }}/{{ repository.name }}
|
||||
</GithubLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
:value="repository.total_commits"
|
||||
label="Commits"
|
||||
icon="fas fa-code-commit"
|
||||
icon-color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="repository.total_prs"
|
||||
label="Pull Requests"
|
||||
icon="fas fa-code-pull-request"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="repository.total_reviews"
|
||||
label="Reviews"
|
||||
icon="fas fa-eye"
|
||||
icon-color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="repository.active_contributors"
|
||||
label="Contributors"
|
||||
icon="fas fa-users"
|
||||
icon-color="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contributors -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="repository.contributors"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
|
||||
>
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" />
|
||||
</template>
|
||||
<template #commits="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.commit_count) }}</span>
|
||||
</template>
|
||||
<template #prs="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.prs_opened) }}</span>
|
||||
</template>
|
||||
<template #reviews="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.reviews_given) }}</span>
|
||||
</template>
|
||||
<template #lines="{ item }">
|
||||
<span class="text-green-500">+{{ formatNumber(item.lines_added) }}</span>
|
||||
<span class="text-gray-400 mx-1">/</span>
|
||||
<span class="text-red-500">-{{ formatNumber(item.lines_deleted) }}</span>
|
||||
</template>
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
{{ formatNumber(item.score?.total || 0) }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import MemberCard from '../components/MemberCard.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import { slugify } from '../composables/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
const team = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Teams' },
|
||||
{ label: team.value?.name || route.params.slug }
|
||||
])
|
||||
|
||||
function loadTeam() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const teams = globalData.value?.teams || []
|
||||
const found = teams.find(t => slugify(t.name) === route.params.slug)
|
||||
|
||||
if (found) {
|
||||
team.value = found
|
||||
} else {
|
||||
error.value = 'Team not found'
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadTeam)
|
||||
watch(() => route.params, loadTeam)
|
||||
watch(globalData, loadTeam)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" message="Loading team..." />
|
||||
<ErrorState v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else-if="team">
|
||||
<PageHeader
|
||||
:title="team.name"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
:subtitle="`${team.members?.length || 0} team members`"
|
||||
>
|
||||
<template #prefix>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full mr-4"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
></div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Team Stats -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
:value="team.total_score"
|
||||
label="Total Score"
|
||||
icon="fas fa-star"
|
||||
icon-color="text-yellow-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="team.aggregated_metrics?.commit_count || 0"
|
||||
label="Commits"
|
||||
icon="fas fa-code-commit"
|
||||
icon-color="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="team.aggregated_metrics?.prs_merged || 0"
|
||||
label="PRs Merged"
|
||||
icon="fas fa-code-merge"
|
||||
icon-color="text-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
:value="team.aggregated_metrics?.reviews_given || 0"
|
||||
label="Reviews"
|
||||
icon="fas fa-eye"
|
||||
icon-color="text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Team Members -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Team Members" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<MemberCard
|
||||
v-for="member in team.member_metrics"
|
||||
:key="member.login"
|
||||
:member="member"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user