Skip to content

Commit 02ce76a

Browse files
committed
🚀 feat: implement BadgeController for GitHub stats badges
- Added BadgeController to handle various badge types (repositories, organization, languages, followers, etc.) - Removed VisitorController as its functionality is now integrated into BadgeController. - Updated database schema to include a badges table with relevant fields. - Implemented caching mechanism for badge rendering to improve performance. - Added migration logic to transfer legacy visitor data to the new badges table. - Updated GitHubClient to fetch badge values for different metrics. - Adjusted routes in index.ts to accommodate new badge endpoints.
1 parent 915e9d6 commit 02ce76a

8 files changed

Lines changed: 688 additions & 148 deletions

File tree

src/components/badge-renderer.ts

Lines changed: 167 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,177 @@
11
import { getTheme, getBadgeTheme } from '../utils/themes.js';
2+
import type { BadgeType, BadgeOptions } from '../types.js';
3+
4+
interface BadgeConfig {
5+
label: string;
6+
iconPath: string;
7+
formatValue?: (n: number) => string;
8+
}
9+
10+
const H = 26;
11+
const ICON_SIZE = 12;
12+
const ICON_PAD_L = 7;
13+
const ICON_GAP = 13;
14+
const LABEL_CHARW = 6.5;
15+
const LABEL_PAD_R = 9;
16+
const VALUE_CHARW = 8;
17+
const VALUE_PAD_H = 12;
18+
const ICON_SCALE = (ICON_SIZE / 14).toFixed(4);
19+
const ICON_TOP = ((H - ICON_SIZE) / 2).toFixed(1);
20+
const TEXT_Y = Math.round(H / 2 + 4);
21+
22+
const BADGE_CONFIGS: Record<BadgeType, BadgeConfig> = {
23+
'visitors': {
24+
label: 'Visitors',
25+
iconPath: 'M 1 7 Q 7 1.5 13 7 Q 7 12.5 1 7 Z M 7 5 A 2 2 0 1 1 6.99 5',
26+
},
27+
'repositories': {
28+
label: 'Repos',
29+
iconPath: 'M 5.25 1.75 a 0.583 0.583 0 0 1 0.354 0.12 l 0.058 0.051 l 1.579 1.579 h 3.841 a 1.75 1.75 0 0 1 1.747 1.647 l 0.003 0.103 v 4.667 a 1.75 1.75 0 0 1 -1.647 1.747 l -0.103 0.003 h -8.167 a 1.75 1.75 0 0 1 -1.747 -1.647 l -0.003 -0.103 v -6.417 a 1.75 1.75 0 0 1 1.647 -1.747 l 0.103 0.003 h 2.333 z',
30+
},
31+
'organization': {
32+
label: 'Orgs',
33+
iconPath: 'M 1.75 12.25 l 10.5 0 M 5.25 4.67 l 0.58 0 M 5.25 7 l 0.58 0 M 5.25 9.33 l 0.58 0 M 8.17 4.67 l 0.58 0 M 8.17 7 l 0.58 0 M 8.17 9.33 l 0.58 0 M 2.92 12.25 v -9.33 a 1.17 1.17 0 0 1 1.17 -1.17 h 5.83 a 1.17 1.17 0 0 1 1.17 1.17 v 9.33',
34+
},
35+
'languages': {
36+
label: 'Languages',
37+
iconPath: 'M 4.08 4.67 l -2.33 2.33 l 2.33 2.33 M 9.92 4.67 l 2.33 2.33 l -2.33 2.33 M 8.17 2.33 l -2.33 9.33',
38+
},
39+
'followers': {
40+
label: 'Followers',
41+
iconPath: 'M 5.83 7.58 a 1.167 1.167 0 1 0 2.333 0 a 1.167 1.167 0 0 0 -2.333 0 M 4.67 12.25 v -0.583 a 1.167 1.167 0 0 1 1.167 -1.167 h 2.333 a 1.167 1.167 0 0 1 1.167 1.167 v 0.583 M 8.75 2.92 a 1.167 1.167 0 1 0 2.333 0 a 1.167 1.167 0 0 0 -2.333 0 M 9.92 5.83 h 1.167 a 1.167 1.167 0 0 1 1.167 1.167 v 0.583 M 2.92 2.92 a 1.167 1.167 0 1 0 2.333 0 a 1.167 1.167 0 0 0 -2.333 0 M 1.75 7.58 v -0.583 a 1.167 1.167 0 0 1 1.167 -1.167 h 1.167',
42+
},
43+
'total-stars': {
44+
label: 'Stars',
45+
iconPath: 'M 4.808 4.281 l -3.722 0.540 l -0.066 0.013 a 0.583 0.583 0 0 0 -0.257 0.982 l 2.696 2.624 l -0.636 3.707 l -0.008 0.064 a 0.583 0.583 0 0 0 0.854 0.551 l 3.328 -1.750 l 3.321 1.750 l 0.058 0.027 a 0.583 0.583 0 0 0 0.789 -0.642 l -0.636 -3.707 l 2.697 -2.625 l 0.046 -0.050 a 0.583 0.583 0 0 0 -0.369 -0.945 l -3.722 -0.540 l -1.663 -3.372 a 0.583 0.583 0 0 0 -1.047 0 l -1.664 3.372 z',
46+
},
47+
'total-contributors': {
48+
label: 'Contributors',
49+
iconPath: 'M 2.917 4.083 a 2.333 2.333 0 1 0 4.667 0 a 2.333 2.333 0 1 0 -4.667 0 M 1.75 12.25 v -1.167 a 2.333 2.333 0 0 1 2.333 -2.333 h 2.333 a 2.333 2.333 0 0 1 2.333 2.333 v 1.167 M 9.333 1.826 a 2.333 2.333 0 0 1 0 4.521 M 12.25 12.25 v -1.167 a 2.333 2.333 0 0 0 -1.75 -2.246',
50+
},
51+
'total-commits': {
52+
label: 'Commits',
53+
iconPath: 'M 7 3.5 A 3.5 3.5 0 1 1 6.99 3.5 M 1 7 L 3.5 7 M 10.5 7 L 13 7',
54+
},
55+
'total-code-reviews': {
56+
label: 'Reviews',
57+
iconPath: 'M 2.91 7.50 a 4.14 4.14 0 0 0 7.10 2.29 a 1.14 1.14 0 0 1 -0.09 -0.37 l -0.003 -0.087 l 0.003 -0.088 a 1.167 1.167 0 1 1 1.031 1.246 a 5.308 5.308 0 0 1 -9.195 -2.829 a 0.583 0.583 0 0 1 1.155 -0.16 z M 7.0 4.667 a 2.333 2.333 0 1 1 -2.330 2.45 l -0.003 -0.117 l 0.003 -0.117 a 2.333 2.333 0 0 1 2.330 -2.217 z M 7.66 1.80 a 5.31 5.31 0 0 1 4.578 4.534 a 0.583 0.583 0 0 1 -1.155 0.161 a 4.14 4.14 0 0 0 -3.573 -3.537 a 4.14 4.14 0 0 0 -3.528 1.246 a 1.167 1.167 0 1 1 -2.235 0.548 l -0.003 -0.087 l 0.003 -0.088 a 1.167 1.167 0 0 1 1.293 -1.072 a 5.305 5.305 0 0 1 4.621 -1.705 z',
58+
},
59+
'total-issues': {
60+
label: 'Issues',
61+
iconPath: 'M 7 1 A 6 6 0 1 1 6.99 1 M 7 5 L 7 7.5 M 7 10 L 7 10.2',
62+
},
63+
'total-pull-requests': {
64+
label: 'Pull Reqs',
65+
iconPath: 'M 3 2 A 1.5 1.5 0 1 1 2.99 2 M 3 3.5 L 3 12.5 M 11 2 A 1.5 1.5 0 1 1 10.99 2 M 11 3.5 L 11 9.5 M 11 11 A 1.5 1.5 0 1 1 10.99 11 M 3 9 Q 3 13 9 13 L 9.5 13',
66+
},
67+
'total-joined-years': {
68+
label: 'Joined',
69+
iconPath: 'M 2 3 L 12 3 L 12 12 L 2 12 Z M 2 6 L 12 6 M 5 1.5 L 5 4 M 9 1.5 L 9 4 M 5 8.5 L 5 10 M 7 8.5 L 9 8.5',
70+
formatValue: (n) => `${n} yr${n !== 1 ? 's' : ''}`,
71+
},
72+
};
273

374
export class BadgeRenderer {
4-
static generateBadge(username: string, count: number, themeName: string = 'default', customColors?: {
5-
labelColor?: string;
6-
labelBackground?: string;
7-
valueColor?: string;
8-
valueBackground?: string;
9-
}): string {
75+
76+
static visitors(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
77+
return BadgeRenderer._render(BADGE_CONFIGS['visitors'], 'visitors', value, options);
78+
}
79+
80+
static repositories(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
81+
return BadgeRenderer._render(BADGE_CONFIGS['repositories'], 'repositories', value, options);
82+
}
83+
84+
static organization(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
85+
return BadgeRenderer._render(BADGE_CONFIGS['organization'], 'organization', value, options);
86+
}
87+
88+
static languages(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
89+
return BadgeRenderer._render(BADGE_CONFIGS['languages'], 'languages', value, options);
90+
}
91+
92+
static followers(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
93+
return BadgeRenderer._render(BADGE_CONFIGS['followers'], 'followers', value, options);
94+
}
95+
96+
static totalStars(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
97+
return BadgeRenderer._render(BADGE_CONFIGS['total-stars'], 'total-stars', value, options);
98+
}
99+
100+
static totalContributors(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
101+
return BadgeRenderer._render(BADGE_CONFIGS['total-contributors'], 'total-contributors', value, options);
102+
}
103+
104+
static totalCommits(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
105+
return BadgeRenderer._render(BADGE_CONFIGS['total-commits'], 'total-commits', value, options);
106+
}
107+
108+
static totalCodeReviews(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
109+
return BadgeRenderer._render(BADGE_CONFIGS['total-code-reviews'], 'total-code-reviews', value, options);
110+
}
111+
112+
static totalIssues(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
113+
return BadgeRenderer._render(BADGE_CONFIGS['total-issues'], 'total-issues', value, options);
114+
}
115+
116+
static totalPullRequests(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
117+
return BadgeRenderer._render(BADGE_CONFIGS['total-pull-requests'], 'total-pull-requests', value, options);
118+
}
119+
120+
static totalJoinedYears(value: number, options: Omit<BadgeOptions, 'type'> = {}): string {
121+
return BadgeRenderer._render(BADGE_CONFIGS['total-joined-years'], 'total-joined-years', value, options);
122+
}
123+
124+
/**
125+
* Render a badge SVG for any badge type.
126+
* @param value The numeric value to display (visitors count, stars, etc.)
127+
* @param options Badge display options including `type`, `theme`, and optional color overrides.
128+
*/
129+
/**
130+
* Render a badge SVG for any badge type.
131+
* @param value The numeric value to display (visitors count, stars, etc.)
132+
* @param options Badge display options including `type`, `theme`, and optional color overrides.
133+
*/
134+
static generateBadge(value: number, options: BadgeOptions): string {
135+
const type: BadgeType = options.type ?? 'visitors';
136+
return BadgeRenderer._render(BADGE_CONFIGS[type], type, value, options);
137+
}
138+
139+
private static _resolveColor(override: string | undefined, fallback: string): string {
140+
if (!override) return fallback;
141+
return override.startsWith('#') ? override : `#${override}`;
142+
}
143+
144+
private static _render(
145+
config: BadgeConfig,
146+
type: BadgeType,
147+
value: number,
148+
options: Omit<BadgeOptions, 'type'>
149+
): string {
150+
const themeName = options.theme ?? 'default';
10151
const theme = getTheme(themeName);
11152
const badgeTheme = getBadgeTheme(themeName);
153+
const rc = BadgeRenderer._resolveColor;
12154

13-
const finalLabelColor = customColors?.labelColor || badgeTheme.labelColor;
14-
const finalLabelBg = customColors?.labelBackground || badgeTheme.labelBackground;
15-
const finalValueColor = customColors?.valueColor || badgeTheme.valueColor;
16-
const finalValueBg = customColors?.valueBackground || badgeTheme.valueBackground;
155+
const labelColor = rc(options.labelColor, badgeTheme.labelColor);
156+
const labelBg = rc(options.labelBackground, badgeTheme.labelBackground);
157+
const valueColor = rc(options.valueColor, badgeTheme.valueColor);
158+
const valueBg = rc(options.valueBackground, badgeTheme.valueBackground);
17159

18160
const fontName = theme.fontName || 'Orbitron';
19-
const fontFamily = theme.fontFamily || `'${fontName}', 'Ubuntu', 'sans-serif'`;
20-
21-
// Formatted count
22-
const formattedCount = count.toLocaleString();
23-
24-
// Calculate widths based on text length (estimations)
25-
const labelText = "VISITORS";
26-
const labelWidth = labelText.length * 8 + 20;
27-
const countWidth = formattedCount.length * 10 + 20;
28-
const totalWidth = labelWidth + countWidth;
29-
const height = 28;
30-
31-
return `
32-
<svg width="${totalWidth}" height="${height}" viewBox="0 0 ${totalWidth} ${height}" fill="none" xmlns="http://www.w3.org/2000/svg">
33-
<defs>
34-
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
35-
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur"/>
36-
<feMerge>
37-
<feMergeNode in="blur"/>
38-
<feMergeNode in="SourceGraphic"/>
39-
</feMerge>
40-
</filter>
41-
</defs>
42-
43-
<style>
44-
@font-face {
45-
font-family: '${fontName}';
46-
font-style: normal;
47-
font-weight: 400 900;
48-
font-display: swap;
49-
src: url(/fonts/orbitron.woff2) format('woff2');
50-
}
51-
text {
52-
font-family: ${fontFamily};
53-
font-weight: 700;
54-
text-transform: uppercase;
55-
letter-spacing: 1px;
56-
}
57-
.label-text {
58-
fill: ${finalLabelColor};
59-
font-size: 11px;
60-
}
61-
.count-text {
62-
fill: ${finalValueColor};
63-
font-size: 14px;
64-
}
65-
</style>
66-
67-
<!-- Background -->
68-
<rect width="${totalWidth}" height="${height}" rx="4" fill="${finalValueBg}" stroke="${theme.borderColor}" stroke-width="1" />
69-
70-
<!-- Label Section -->
71-
<path d="M 4 1 H ${labelWidth} V ${height - 1} H 4 Q 1 ${height - 1} 1 ${height - 4} V 4 Q 1 1 4 1 Z" fill="${finalLabelBg}" />
72-
<text x="${labelWidth / 2}" y="${height / 2 + 4}" text-anchor="middle" class="label-text">${labelText}</text>
73-
74-
<!-- Count Section -->
75-
<text x="${labelWidth + (countWidth / 2)}" y="${height / 2 + 5}" text-anchor="middle" class="count-text" filter="url(#glow)">${formattedCount}</text>
76-
77-
<!-- Accent Line -->
78-
<line x1="${labelWidth}" y1="6" x2="${labelWidth}" y2="${height - 6}" stroke="${theme.borderColor}" stroke-width="1" opacity="0.3" />
79-
</svg>
80-
`.trim();
161+
const fontFamily = theme.fontFamily || `'${fontName}', 'Ubuntu', sans-serif`;
162+
163+
const labelText = (options.customLabel ?? config.label).toUpperCase();
164+
const displayValue = config.formatValue ? config.formatValue(value) : value.toLocaleString();
165+
166+
const labelTextW = Math.ceil(labelText.length * LABEL_CHARW);
167+
const labelSecW = ICON_PAD_L + ICON_SIZE + ICON_GAP + labelTextW + LABEL_PAD_R;
168+
const valueTextW = Math.ceil(displayValue.length * VALUE_CHARW);
169+
const valueSecW = VALUE_PAD_H + valueTextW + VALUE_PAD_H;
170+
const totalWidth = labelSecW + valueSecW;
171+
172+
const labelTextX = (ICON_PAD_L + ICON_SIZE + ICON_GAP + labelTextW / 2).toFixed(1);
173+
const valueTextX = (labelSecW + valueSecW / 2).toFixed(1);
174+
175+
return `<svg width="${totalWidth}" height="${H}" viewBox="0 0 ${totalWidth} ${H}" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><filter id="glow-${type}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter><clipPath id="clip-${type}"><rect width="${totalWidth}" height="${H}" rx="4"/></clipPath></defs><style>@font-face{font-family:'${fontName}';font-style:normal;font-weight:400 900;font-display:swap;src:url(/fonts/orbitron.woff2) format('woff2')}.badge-text{font-family:${fontFamily};font-weight:700;letter-spacing:.8px;text-transform:uppercase}</style><rect width="${totalWidth}" height="${H}" rx="4" fill="${valueBg}" stroke="${theme.borderColor}" stroke-width="1"/><path clip-path="url(#clip-${type})" d="M 0 0 H ${labelSecW} V ${H} H 0 Z" fill="${labelBg}"/><g transform="translate(${ICON_PAD_L},${ICON_TOP}) scale(${ICON_SCALE})"><path d="${config.iconPath}" stroke="${labelColor}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity=".9"/></g><text x="${labelTextX}" y="${TEXT_Y}" text-anchor="middle" class="badge-text" fill="${labelColor}" font-size="9.5">${labelText}</text><line x1="${labelSecW}" y1="5" x2="${labelSecW}" y2="${H - 5}" stroke="${theme.borderColor}" stroke-width="1" opacity=".35"/><text x="${valueTextX}" y="${TEXT_Y}" text-anchor="middle" class="badge-text" fill="${valueColor}" font-size="12" filter="url(#glow-${type})">${displayValue}</text></svg>`;
81176
}
82177
}

0 commit comments

Comments
 (0)