Skip to content

Commit a1de7d1

Browse files
committed
feat: implement dynamic visitor badge endpoint with custom theming and updated documentation.
1 parent 36f9a3c commit a1de7d1

File tree

6 files changed

+213
-2
lines changed

6 files changed

+213
-2
lines changed

src/components/badge-renderer.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { getTheme, getBadgeTheme } from '../utils/themes.js';
2+
3+
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 {
10+
const theme = getTheme(themeName);
11+
const badgeTheme = getBadgeTheme(themeName);
12+
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;
17+
18+
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();
81+
}
82+
}

src/controllers/visitor.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Request, Response } from 'express';
2+
import { db } from '../db/index.js';
3+
import { visitors } from '../db/schema.js';
4+
import { eq, sql } from 'drizzle-orm';
5+
import { BadgeRenderer } from '../components/badge-renderer.js';
6+
7+
export class VisitorController {
8+
static routeDocs = {
9+
requiredParams: ['username'],
10+
optionalParams: ['theme', 'labelColor', 'labelBackground', 'valueColor', 'valueBackground'],
11+
payload: null,
12+
example: '/badge?username=pphatdev&theme=tokyo&labelColor=ff0000'
13+
};
14+
15+
static async getBadge(req: Request, res: Response) {
16+
try {
17+
const {
18+
username,
19+
theme = 'default',
20+
labelColor,
21+
labelBackground,
22+
valueColor,
23+
valueBackground
24+
} = req.query;
25+
26+
if (!username || typeof username !== 'string') {
27+
return res.status(400).send('Username is required');
28+
}
29+
30+
// Increment or insert visitor count
31+
const result = await db.insert(visitors)
32+
.values({ username, count: 1 })
33+
.onConflictDoUpdate({
34+
target: visitors.username,
35+
set: { count: sql`${visitors.count} + 1` }
36+
})
37+
.returning();
38+
39+
const count = result[0]?.count || 1;
40+
const svg = BadgeRenderer.generateBadge(username, count, theme as string, {
41+
labelColor: labelColor as string | undefined,
42+
labelBackground: labelBackground as string | undefined,
43+
valueColor: valueColor as string | undefined,
44+
valueBackground: valueBackground as string | undefined
45+
});
46+
47+
res.setHeader('Content-Type', 'image/svg+xml');
48+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); // Always fresh for counter
49+
res.send(svg);
50+
} catch (error) {
51+
console.error('Error in VisitorController:', error);
52+
res.status(500).send(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
53+
}
54+
}
55+
}

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GitHubClient } from './utils/github-client.js';
55
import { StatsController } from './controllers/stats.js';
66
import { LanguageController } from './controllers/languages.js';
77
import { GraphController } from './controllers/graph.js';
8+
import { VisitorController } from './controllers/visitor.js';
89
import path from 'path';
910
import { fileURLToPath } from 'url';
1011

@@ -32,7 +33,8 @@ type RouteInfo = {
3233
const routeDocs: Record<string, Omit<RouteInfo, 'method' | 'path'>> = {
3334
'GET /stats': StatsController.routeDocs,
3435
'GET /languages': LanguageController.routeDocs,
35-
'GET /graph': GraphController.routeDocs
36+
'GET /graph': GraphController.routeDocs,
37+
'GET /badge': VisitorController.routeDocs
3638
};
3739

3840
const getRoutes = (): RouteInfo[] => {
@@ -103,6 +105,7 @@ GraphController.initialize(githubClient, cache, CACHE_DURATION);
103105
app.get('/stats', StatsController.getSvg);
104106
app.get('/languages', LanguageController.getSvg);
105107
app.get('/graph', GraphController.getSvg);
108+
app.get('/badge', VisitorController.getBadge);
106109

107110
app.listen(PORT, () => {
108111
console.log(`🚀 GitHub Stats server running on ${PROTOCOL}://localhost:${PORT}`);

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ export interface Theme {
3636
fontUrl?: string;
3737
}
3838

39+
export interface BadgeTheme {
40+
labelColor: string;
41+
labelBackground: string;
42+
valueColor: string;
43+
valueBackground: string;
44+
}
45+
3946
export interface ThemeOverrides {
4047
theme?: string;
4148
bgColor?: string;

src/utils/themes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { Theme } from '../types.js';
1+
import { Theme, BadgeTheme } from '../types.js';
22
import { baseThemes } from './themes/base.js';
33
import { graphThemes } from './themes/graph.js';
4+
import { badgeThemes } from './themes/badge.js';
45

56
const defaultFontName = 'Orbitron';
67
const defaultFontFamily = `'${defaultFontName}', 'Ubuntu', 'sans-serif'`;
78
const defaultFontUrl = '/fonts/orbitron.woff2';
89

910
export const themes: { [key: string]: Theme } = { ...baseThemes, ...graphThemes };
11+
export { badgeThemes };
1012

1113
/**
1214
* Normalises a theme name for fuzzy lookup:
@@ -51,4 +53,8 @@ export function getTheme(themeName: string = 'default', customColors?: {
5153
...(customColors?.textColor && { textColor: customColors.textColor }),
5254
...(customColors?.titleColor && { titleColor: customColors.titleColor, iconColor: customColors.titleColor }),
5355
};
56+
}
57+
58+
export function getBadgeTheme(themeName: string = 'default'): BadgeTheme {
59+
return badgeThemes[themeName] || badgeThemes.default;
5460
}

src/utils/themes/badge.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { BadgeTheme } from '../../types.js';
2+
3+
export const badgeThemes: { [key: string]: BadgeTheme } = {
4+
default: {
5+
labelColor: "#ffffff",
6+
labelBackground: "#333333",
7+
valueColor: "#ffffff",
8+
valueBackground: "#4c1a8a"
9+
},
10+
aurora: {
11+
labelColor: '#c8ffd4',
12+
labelBackground: '#0a3026',
13+
valueColor: '#020c12',
14+
valueBackground: '#00e676',
15+
},
16+
matrix: {
17+
labelColor: '#39ff14',
18+
labelBackground: '#003300',
19+
valueColor: '#000000',
20+
valueBackground: '#00cc33',
21+
},
22+
inferno: {
23+
labelColor: '#ffcf77',
24+
labelBackground: '#3d0a00',
25+
valueColor: '#0d0200',
26+
valueBackground: '#ff4500',
27+
},
28+
ocean: {
29+
labelColor: '#b2f0ff',
30+
labelBackground: '#0a2a40',
31+
valueColor: '#020d1a',
32+
valueBackground: '#0099cc',
33+
},
34+
neon: {
35+
labelColor: '#ff99ee',
36+
labelBackground: '#3d0050',
37+
valueColor: '#0a000f',
38+
valueBackground: '#cc00ff',
39+
},
40+
solar: {
41+
labelColor: '#ffe88a',
42+
labelBackground: '#3a2800',
43+
valueColor: '#0d0900',
44+
valueBackground: '#f5a623',
45+
},
46+
galaxy: {
47+
labelColor: '#e9d5ff',
48+
labelBackground: '#1e0a40',
49+
valueColor: '#05020f',
50+
valueBackground: '#8b5cf6',
51+
},
52+
'github-dark': {
53+
labelColor: '#c9d1d9',
54+
labelBackground: '#21262d',
55+
valueColor: '#0d1117',
56+
valueBackground: '#39d353',
57+
},
58+
};

0 commit comments

Comments
 (0)