Skip to content

Commit bf4bdbf

Browse files
committed
feat: enhance badge collection with padding and effects support in API
1 parent 037e062 commit bf4bdbf

3 files changed

Lines changed: 103 additions & 15 deletions

File tree

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,13 @@ for more detail checkout [Here](docs/example/badge-collection.md)
258258

259259
![badge-collection-default](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories)
260260

261-
### Layout Controls (columns + gap)
261+
### Layout Controls (columns + gap + padding)
262262

263263
```
264-
![badge-collection-layout](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues,followers&columns=2&gap=8)
264+
![badge-collection-layout](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues,followers&columns=2&gap=8&padding=12)
265265
```
266266

267-
![badge-collection-layout](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues,followers&columns=2&gap=8)
267+
![badge-collection-layout](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues,followers&columns=2&gap=8&padding=12)
268268

269269
### Multiple Themes (cycled per badge)
270270

@@ -274,6 +274,16 @@ for more detail checkout [Here](docs/example/badge-collection.md)
274274

275275
![badge-collection-multi-theme](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues,followers&theme=galaxy,aurora,ocean)
276276

277+
### Effects (`glow` | `wave`)
278+
279+
```
280+
![badge-collection-glow](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues&effect=glow)
281+
![badge-collection-wave](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues&effect=wave)
282+
```
283+
284+
![badge-collection-glow](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues&effect=glow)
285+
![badge-collection-wave](https://stats.pphat.top/badge/collection?username=pphatdev&type=visitors,total-stars,repositories,total-issues&effect=wave)
286+
277287

278288
# 📁 Project Badge Examples
279289

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "github-stats",
3-
"version": "2.0.3",
3+
"version": "2.0.4",
44
"description": "Generate dynamic GitHub stats cards for your README",
55
"main": "dist/index.js",
66
"type": "module",

src/controllers/badge-collection.controller.ts

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ export class BadgeCollectionController {
5757
private static readonly DEFAULT_COLUMNS = 50;
5858
private static readonly MAX_COLUMNS = 50;
5959
private static readonly DEFAULT_GAP = 5;
60+
private static readonly DEFAULT_PADDING = 0;
6061

6162
/** Route documentation */
6263
static readonly routeDocs = {
6364
collection: {
6465
requiredParams: ['username', 'type'],
65-
optionalParams: ['columns', 'gap', 'theme', 'customLabel', 'labelColor', 'labelBackground', 'iconColor', 'valueColor', 'valueBackground'],
66+
optionalParams: ['columns', 'gap', 'padding', 'theme', 'effect', 'customLabel', 'labelColor', 'labelBackground', 'iconColor', 'valueColor', 'valueBackground'],
6667
payload: null,
67-
example: '/badge/collection?username=pphatdev&type=visitors,total-stars,repositories&columns=1&theme=galaxy',
68+
example: '/badge/collection?username=pphatdev&type=visitors,total-stars,repositories&columns=1&gap=8&padding=12&theme=galaxy&effect=wave',
6869
},
6970
};
7071

@@ -127,6 +128,22 @@ export class BadgeCollectionController {
127128
return parsed;
128129
}
129130

131+
private static normalizePadding(value: unknown): number | null {
132+
if (typeof value === 'undefined') return BadgeCollectionController.DEFAULT_PADDING;
133+
if (typeof value !== 'string') return null;
134+
const parsed = Number.parseInt(value, 10);
135+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 200) return null;
136+
return parsed;
137+
}
138+
139+
private static normalizeEffect(value: unknown): 'glow' | 'wave' | undefined | null {
140+
if (typeof value === 'undefined') return undefined;
141+
if (typeof value !== 'string') return null;
142+
const normalized = value.trim().toLowerCase();
143+
if (normalized === 'glow' || normalized === 'wave') return normalized;
144+
return null;
145+
}
146+
130147
private static parseSharedOptions(req: Request): Omit<BadgeOptions, 'type'> {
131148
const { customLabel, labelColor, labelBackground, iconColor, valueColor, valueBackground } = req.query;
132149
return {
@@ -206,6 +223,8 @@ export class BadgeCollectionController {
206223
badgeSvgs: string[],
207224
columns: number,
208225
gap: number,
226+
padding: number,
227+
effect: 'glow' | 'wave' | undefined,
209228
): string {
210229
const dims = badgeSvgs.map(
211230
(svg) => BadgeCollectionController.extractSvgDimensions(svg) ?? { width: 200, height: 34 }
@@ -233,17 +252,34 @@ export class BadgeCollectionController {
233252
});
234253

235254
const colXOffsets = colWidths.reduce<number[]>((acc, _w, i) => {
236-
acc.push(i === 0 ? 0 : acc[i - 1] + colWidths[i - 1] + gap);
255+
acc.push(i === 0 ? padding : acc[i - 1] + colWidths[i - 1] + gap);
237256
return acc;
238257
}, []);
239258

240259
const rowYOffsets = rowHeights.reduce<number[]>((acc, _h, i) => {
241-
acc.push(i === 0 ? 0 : acc[i - 1] + rowHeights[i - 1] + gap);
260+
acc.push(i === 0 ? padding : acc[i - 1] + rowHeights[i - 1] + gap);
242261
return acc;
243262
}, []);
244263

245-
const totalWidth = colWidths.reduce((a, b) => a + b, 0) + gap * Math.max(0, totalCols - 1);
246-
const totalHeight = rowHeights.reduce((a, b) => a + b, 0) + gap * Math.max(0, totalRows - 1);
264+
const contentWidth = colWidths.reduce((a, b) => a + b, 0) + gap * Math.max(0, totalCols - 1);
265+
const contentHeight = rowHeights.reduce((a, b) => a + b, 0) + gap * Math.max(0, totalRows - 1);
266+
const totalWidth = contentWidth + (padding * 2);
267+
const totalHeight = contentHeight + (padding * 2);
268+
const defs: string[] = [];
269+
270+
const waveStyles = effect === 'wave'
271+
? `<style>
272+
@keyframes badges-wave {
273+
0%, 100% { transform: translateY(0); }
274+
50% { transform: translateY(-5px); }
275+
}
276+
.badge-wave {
277+
animation: badges-wave 1.9s ease-in-out infinite;
278+
transform-box: fill-box;
279+
transform-origin: center;
280+
}
281+
</style>`
282+
: '';
247283

248284
const embeds = badgeSvgs.map((svgContent, index) => {
249285
const col = index % totalCols;
@@ -252,22 +288,42 @@ export class BadgeCollectionController {
252288
const y = rowYOffsets[row];
253289
const { width, height } = dims[index];
254290
const prefix = `bc-${index}`;
291+
const delay = (col * 0.12) + (row * 0.08);
292+
const className = effect === 'wave' ? 'badge-wave' : '';
293+
const styleAttr = effect === 'wave' ? `animation-delay: ${delay.toFixed(2)}s;` : '';
294+
const glowFilterId = effect === 'glow' ? `badge-glow-${index}` : undefined;
295+
296+
if (glowFilterId) {
297+
defs.push(`<filter id="${glowFilterId}" x="-40%" y="-40%" width="180%" height="180%">
298+
<feGaussianBlur in="SourceGraphic" stdDeviation="1.4" result="blur"/>
299+
<feMerge>
300+
<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>
301+
</feMerge>
302+
</filter>`);
303+
}
255304

256305
const scoped = BadgeCollectionController.scopeBadgeSvgIds(svgContent.trim(), prefix);
257306

258307
// Rewrite the root <svg> tag with explicit position, size, and no xmlns
259308
return scoped.replace(/<svg\b([^>]*)>/i, (_match, attrs: string) => {
260309
const cleaned = attrs
261-
.replace(/\s+(?:x|y|width|height|xmlns(?::[a-z]+)?)="[^"]*"/gi, '')
262-
.replace(/\s+(?:x|y|width|height|xmlns(?::[a-z]+)?)='[^']*'/gi, '')
310+
.replace(/\s+(?:x|y|width|height|xmlns(?::[a-z]+)?|class|style|filter)="[^"]*"/gi, '')
311+
.replace(/\s+(?:x|y|width|height|xmlns(?::[a-z]+)?|class|style|filter)='[^']*'/gi, '')
263312
.trim();
264-
return `<svg${cleaned ? ` ${cleaned}` : ''} x="${x}" y="${y}" width="${width}" height="${height}">`;
313+
const classPart = className ? ` class="${className}"` : '';
314+
const stylePart = styleAttr ? ` style="${styleAttr}"` : '';
315+
const filterPart = glowFilterId ? ` filter="url(#${glowFilterId})"` : '';
316+
return `<svg${cleaned ? ` ${cleaned}` : ''}${classPart}${stylePart}${filterPart} x="${x}" y="${y}" width="${width}" height="${height}">`;
265317
});
266318
});
267319

268320
return `<?xml version="1.0" encoding="UTF-8"?>
269321
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}" viewBox="0 0 ${totalWidth} ${totalHeight}" role="img" aria-label="Badge collection">
270322
<title>Badge collection</title>
323+
${defs.length > 0 ? `<defs>
324+
${defs.join('\n ')}
325+
</defs>` : ''}
326+
${waveStyles}
271327
${embeds.join('\n ')}
272328
</svg>
273329
`;
@@ -279,15 +335,19 @@ export class BadgeCollectionController {
279335
options: Omit<BadgeOptions, 'type'>,
280336
columns: number,
281337
gap: number,
338+
padding: number,
282339
themes: string[],
340+
effect: 'glow' | 'wave' | undefined,
283341
): string {
284342
return [
285343
'badge-collection',
286344
`user:${username}`,
287345
`types:${types.join(',')}`,
288346
`themes:${themes.join(',')}`,
347+
`effect:${effect ?? 'none'}`,
289348
`columns:${columns}`,
290349
`gap:${gap}`,
350+
`padding:${padding}`,
291351
`labelColor:${options.labelColor ?? ''}`,
292352
`labelBg:${options.labelBackground ?? ''}`,
293353
`iconColor:${options.iconColor ?? ''}`,
@@ -353,6 +413,24 @@ export class BadgeCollectionController {
353413
return;
354414
}
355415

416+
const padding = BadgeCollectionController.normalizePadding(req.query.padding);
417+
if (padding === null) {
418+
res.status(400).json({
419+
error: 'Invalid padding parameter',
420+
message: 'padding must be an integer between 0 and 200',
421+
});
422+
return;
423+
}
424+
425+
const effect = BadgeCollectionController.normalizeEffect(req.query.effect);
426+
if (effect === null) {
427+
res.status(400).json({
428+
error: 'Invalid effect parameter',
429+
message: 'effect must be one of: glow, wave',
430+
});
431+
return;
432+
}
433+
356434
// 4. Parse display options
357435
const sharedOptions = BadgeCollectionController.parseSharedOptions(req);
358436

@@ -371,7 +449,7 @@ export class BadgeCollectionController {
371449
}
372450

373451
// 5. Check in-memory collection cache
374-
const cacheKey = BadgeCollectionController.generateCacheKey(username, types, sharedOptions, columns, gap, themes);
452+
const cacheKey = BadgeCollectionController.generateCacheKey(username, types, sharedOptions, columns, gap, padding, themes, effect);
375453
const cached = BadgeCollectionController.svgCache.get(cacheKey);
376454
if (cached) {
377455
if (req.headers['if-none-match'] === cached.etag) {
@@ -399,7 +477,7 @@ export class BadgeCollectionController {
399477
});
400478

401479
// Compose into collection
402-
return BadgeCollectionController.buildCollectionSvg(badgeSvgs, columns, gap);
480+
return BadgeCollectionController.buildCollectionSvg(badgeSvgs, columns, gap, padding, effect);
403481
})();
404482

405483
BadgeCollectionController.pendingCollections.set(cacheKey, pending);

0 commit comments

Comments
 (0)