@@ -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 ( / < s v g \b ( [ ^ > ] * ) > / i, ( _match , attrs : string ) => {
260309 const cleaned = attrs
261- . replace ( / \s + (?: x | y | w i d t h | h e i g h t | x m l n s (?: : [ a - z ] + ) ? ) = " [ ^ " ] * " / gi, '' )
262- . replace ( / \s + (?: x | y | w i d t h | h e i g h t | x m l n s (?: : [ a - z ] + ) ? ) = ' [ ^ ' ] * ' / gi, '' )
310+ . replace ( / \s + (?: x | y | w i d t h | h e i g h t | x m l n s (?: : [ a - z ] + ) ? | c l a s s | s t y l e | f i l t e r ) = " [ ^ " ] * " / gi, '' )
311+ . replace ( / \s + (?: x | y | w i d t h | h e i g h t | x m l n s (?: : [ a - z ] + ) ? | c l a s s | s t y l e | f i l t e r ) = ' [ ^ ' ] * ' / 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