Skip to content

Commit e315a4b

Browse files
authored
Merge pull request #49 from wontory/badge
feat: add badge SVG generation feature
2 parents e80309b + 6358f00 commit e315a4b

39 files changed

Lines changed: 2219 additions & 1358 deletions

README.md

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
<img src="https://tech-stack.wontory.dev/api/orbit?text=tech-stack&slugs=typescript,simpleicons,tailwindcss,nextdotjs" alt="tech-stack" />
77
</p>
88

9+
<p align="center">
10+
<img src="https://tech-stack.wontory.dev/api/badge?slug=nextdotjs&text=Next.js&highlight=true" alt="Next.js" />
11+
<img src="https://tech-stack.wontory.dev/api/badge?slug=typescript&text=TypeScript&highlight=true" alt="TypeScript" />
12+
<img src="https://tech-stack.wontory.dev/api/badge?slug=tailwindcss&text=Tailwind%20CSS&highlight=true" alt="Tailwind CSS" />
13+
<img src="https://tech-stack.wontory.dev/api/badge?slug=simpleicons&text=Simple%20Icons&highlight=true" alt="Simple Icons" />
14+
</p>
15+
916
> Showcase your tech stack with beautiful animated SVGs
1017
1118
**tech-stack** is a web application that lets you visualize your technology stack in style. Make your GitHub profile, project README, or portfolio site stand out with eye-catching animated graphics.
@@ -22,6 +29,14 @@
2229
- **Dark mode** fully supported
2330
- **One-click embedding**: Copy markdown code and paste anywhere
2431

32+
### 🏷️ Badge
33+
34+
- **Shields.io-style badges** with animated effects
35+
- **Highlight mode**: Trailing light and shine animation with icon color
36+
- **Customizable**: Text, icon, text color, icon color, and background color
37+
- **3,200+ brand icons** supported (powered by Simple Icons)
38+
- **One-click embedding**: Copy markdown code and paste anywhere
39+
2540
### 🎯 Use Cases
2641

2742
- **GitHub profile enhancement**: Show your tech stack at a glance
@@ -43,6 +58,8 @@
4358

4459
You can generate SVGs using just the URL, without the web editor:
4560

61+
#### Orbit API
62+
4663
```markdown
4764
![My Tech Stack](https://tech-stack.wontory.dev/api/orbit?text=MyStack&slugs=typescript,react,nextdotjs)
4865
```
@@ -56,17 +73,49 @@ You can generate SVGs using just the URL, without the web editor:
5673

5774
</details>
5875

59-
#### Parameters
76+
**Parameters**
77+
78+
| Parameter | Description | Example |
79+
|-----------|-------------|---------|
80+
| `text` | Center text | `MyStack` |
81+
| `slugs` | Simple Icons slugs (comma-separated) | `typescript,react` |
82+
83+
#### Badge API
84+
85+
```markdown
86+
![Next.js](https://tech-stack.wontory.dev/api/badge?slug=nextdotjs&text=Next.js&highlight=true)
87+
```
88+
89+
<details>
90+
<summary>Preview</summary>
91+
92+
<p align="center">
93+
<img src="https://tech-stack.wontory.dev/api/badge?slug=nextdotjs&text=Next.js&highlight=true" alt="Next.js" />
94+
<img src="https://tech-stack.wontory.dev/api/badge?slug=typescript&text=TypeScript" alt="TypeScript" />
95+
</p>
96+
97+
</details>
98+
99+
**Parameters**
60100

61-
- `text`: Text to display in the center
62-
- `slugs`: Simple Icons identifiers (comma-separated)
101+
| Parameter | Description | Example |
102+
|-----------|-------------|---------|
103+
| `slug` | Simple Icons slug | `nextdotjs` |
104+
| `text` | Badge text | `Next.js` |
105+
| `highlight` | Enable trailing light and shine effect | `true` |
106+
| `textColor` | Text color (hex) | `ffffff` |
107+
| `iconColor` | Icon color (hex) | `00ff00` |
108+
| `bgColor` | Background color (hex) | `1a1a2e` |
63109

64110
#### Examples
65111

66112
```markdown
67113
![Backend](https://tech-stack.wontory.dev/api/orbit?text=Backend&slugs=nodedotjs,postgresql,redis,docker)
68114

69115
![Frontend](https://tech-stack.wontory.dev/api/orbit?text=Frontend&slugs=react,typescript,tailwindcss,vite)
116+
117+
![Next.js](https://tech-stack.wontory.dev/api/badge?slug=nextdotjs&text=Next.js&highlight=true)
118+
![TypeScript](https://tech-stack.wontory.dev/api/badge?slug=typescript&text=TypeScript)
70119
```
71120

72121
<details>
@@ -80,6 +129,11 @@ You can generate SVGs using just the URL, without the web editor:
80129
<img src="https://tech-stack.wontory.dev/api/orbit?text=Frontend&slugs=react,typescript,tailwindcss,vite" alt="Frontend" />
81130
</p>
82131

132+
<p align="center">
133+
<img src="https://tech-stack.wontory.dev/api/badge?slug=nextdotjs&text=Next.js&highlight=true" alt="Next.js" />
134+
<img src="https://tech-stack.wontory.dev/api/badge?slug=typescript&text=TypeScript" alt="TypeScript" />
135+
</p>
136+
83137
</details>
84138

85139
### 3. Embed Anywhere
@@ -149,7 +203,7 @@ This project is built with:
149203
- [x] Real-time preview and customization
150204
- [x] API endpoint
151205
- [x] Dark mode support
152-
- [ ] Badge-style SVG (in development)
206+
- [x] Badge-style SVG with animated highlight effect
153207
- [ ] More animation options
154208

155209
## 🤝 Contributing

apps/web/app/api/badge/route.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { generateBadgeSvg } from '@tech-stack/badge'
2+
import type { NextRequest } from 'next/server'
3+
import { resolveSimpleIcon } from '#utils/simple-icon'
4+
5+
const HEX_COLOR = /^[0-9a-fA-F]{3,8}$/
6+
7+
export async function GET(request: NextRequest) {
8+
const { searchParams } = request.nextUrl
9+
const slug = searchParams.get('slug') || ''
10+
const text = (searchParams.get('text') || '').slice(0, 100)
11+
const highlight = searchParams.get('highlight') === 'true'
12+
const rawTextColor = searchParams.get('textColor') || ''
13+
const rawIconColor = searchParams.get('iconColor') || ''
14+
const rawBgColor = searchParams.get('bgColor') || ''
15+
const textColor = HEX_COLOR.test(rawTextColor) ? rawTextColor : ''
16+
const iconColor = HEX_COLOR.test(rawIconColor) ? rawIconColor : ''
17+
const bgColor = HEX_COLOR.test(rawBgColor) ? rawBgColor : ''
18+
19+
const icon = slug ? resolveSimpleIcon(slug) : undefined
20+
21+
if (slug && !icon) {
22+
return Response.json(
23+
{ error: `Icon with slug "${slug}" not found` },
24+
{ status: 404 },
25+
)
26+
}
27+
28+
const badgeSvg = generateBadgeSvg(
29+
{ slug, text, highlight, textColor, iconColor, bgColor },
30+
icon ?? null,
31+
)
32+
return new Response(badgeSvg, {
33+
headers: {
34+
'content-type': 'image/svg+xml',
35+
'cache-control': 'public, max-age=3600, s-maxage=3600',
36+
},
37+
})
38+
}

apps/web/app/api/orbit/route.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,32 @@
11
import { generateOrbitSvg } from '@tech-stack/orbit'
22
import type { NextRequest } from 'next/server'
3-
import type { SimpleIcon } from 'simple-icons'
4-
import * as simpleIcons from 'simple-icons/icons'
5-
6-
type SimpleIconKey = keyof typeof simpleIcons
3+
import { resolveSimpleIcon } from '#utils/simple-icon'
74

85
export async function GET(request: NextRequest) {
96
const { searchParams } = request.nextUrl
10-
const text = searchParams.get('text') || ''
7+
const text = (searchParams.get('text') || '').slice(0, 100)
118
const slugs = searchParams.get('slugs') || ''
12-
const icons: SimpleIcon[] = []
9+
10+
const icons = []
1311

1412
if (slugs) {
1513
for (const slug of slugs.split(',')) {
16-
const iconKey = `si${slug.charAt(0).toUpperCase() + slug.slice(1)}`
17-
if (!(iconKey in simpleIcons)) {
14+
const icon = resolveSimpleIcon(slug)
15+
if (!icon) {
1816
return Response.json(
1917
{ error: `Icon with slug "${slug}" not found` },
2018
{ status: 404 },
2119
)
2220
}
23-
icons.push(simpleIcons[iconKey as SimpleIconKey] as SimpleIcon)
21+
icons.push(icon)
2422
}
2523
}
2624

2725
const orbitSvg = generateOrbitSvg(text, icons)
2826
return new Response(orbitSvg, {
2927
headers: {
3028
'content-type': 'image/svg+xml',
29+
'cache-control': 'public, max-age=3600, s-maxage=3600',
3130
},
3231
})
3332
}

apps/web/app/badge/layout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next'
22

33
import { InsetLayout } from '#layouts/inset-layout'
4+
import { BadgeProvider } from '#stores/badge-context'
45

56
export const metadata: Metadata = {
67
title: 'badge',
@@ -11,5 +12,9 @@ export default function PageLayout({
1112
}: Readonly<{
1213
children: React.ReactNode
1314
}>) {
14-
return <InsetLayout title="Badge">{children}</InsetLayout>
15+
return (
16+
<InsetLayout title="Badge">
17+
<BadgeProvider>{children}</BadgeProvider>
18+
</InsetLayout>
19+
)
1520
}

apps/web/app/badge/page.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import Link from 'next/link'
2-
3-
import { Button } from '@tech-stack/ui/components/button'
1+
import { BadgeCustomize } from '#components/badge-customize'
2+
import { BadgePreview } from '#components/badge-preview'
43

54
export default function Page() {
65
return (
7-
<div className="flex flex-1 flex-col items-center justify-center gap-4">
8-
<h1 className="font-bold text-2xl">Work in progress</h1>
9-
<Button size="sm" asChild>
10-
<Link href="/">Home</Link>
11-
</Button>
6+
<div className="grid @5xl/main:grid-cols-2 grid-cols-1 gap-4 px-4 lg:px-6">
7+
<BadgePreview />
8+
<BadgeCustomize />
129
</div>
1310
)
1411
}

apps/web/app/layout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { Metadata } from 'next'
2-
31
import { SidebarInset } from '@tech-stack/ui/components/sidebar'
2+
import type { Metadata } from 'next'
43

54
import '#styles/globals.css'
65
import { Providers } from '#app/providers'

apps/web/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
/// <reference path="./.next/types/routes.d.ts" />
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/web/next.config.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
3-
transpilePackages: ['@tech-stack/ui', '@tech-stack/orbit'],
3+
transpilePackages: [
4+
'@tech-stack/ui',
5+
'@tech-stack/orbit',
6+
'@tech-stack/badge',
7+
],
48
}
59

610
export default nextConfig

apps/web/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
"check-types": "tsc --noEmit"
1111
},
1212
"dependencies": {
13-
"@tanstack/react-virtual": "^3.13.8",
13+
"@tanstack/react-virtual": "^3.13.18",
14+
"@tech-stack/badge": "workspace:*",
1415
"@tech-stack/orbit": "workspace:*",
1516
"@tech-stack/ui": "workspace:*",
1617
"lucide-react": "catalog:icons",
17-
"next": "15.3.8",
18+
"next": "16.1.6",
1819
"next-themes": "catalog:style",
1920
"react": "catalog:react",
2021
"react-dom": "catalog:react",
21-
"react-syntax-highlighter": "^15.6.1",
22+
"react-syntax-highlighter": "^16.1.0",
2223
"react-use-measure": "^2.1.7",
2324
"simple-icons": "catalog:icons"
2425
},
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use client'
2+
3+
import { Input } from '@tech-stack/ui/components/input'
4+
import { Label } from '@tech-stack/ui/components/label'
5+
import { CheckIcon } from 'lucide-react'
6+
7+
import { BadgeHighlightToggle } from '#components/badge-highlight-toggle'
8+
import { BadgeSelectedIcon } from '#components/badge-selected-icon'
9+
import { Customize } from '#components/customize'
10+
import { IconSearch } from '#components/icon-search'
11+
import { TextInput } from '#components/text-input'
12+
import { useBadgeState } from '#stores/badge-context'
13+
14+
export function BadgeCustomize() {
15+
const {
16+
slug,
17+
setSlug,
18+
text,
19+
setText,
20+
textColor,
21+
setTextColor,
22+
iconColor,
23+
setIconColor,
24+
bgColor,
25+
setBgColor,
26+
} = useBadgeState()
27+
28+
return (
29+
<Customize>
30+
<div className="grid w-full items-center gap-2">
31+
<Label htmlFor="text">Text</Label>
32+
<TextInput id="text" text={text} setText={setText} />
33+
</div>
34+
<div className="grid w-full items-center gap-2">
35+
<Label htmlFor="bgColor">Badge Color</Label>
36+
<div className="flex gap-2">
37+
<input
38+
type="color"
39+
id="bgColor"
40+
value={`#${bgColor || 'CCD2D9'}`}
41+
onChange={(e) => setBgColor(e.target.value.replace('#', ''))}
42+
className="h-9 w-9 shrink-0 cursor-pointer rounded-md border"
43+
/>
44+
<Input
45+
type="text"
46+
value={bgColor}
47+
onChange={(e) => setBgColor(e.target.value.replace('#', ''))}
48+
placeholder="Default"
49+
className="w-full"
50+
/>
51+
</div>
52+
</div>
53+
<div className="grid w-full grid-cols-2 gap-2">
54+
<div className="grid items-center gap-2">
55+
<Label htmlFor="textColor">Text Color</Label>
56+
<div className="flex gap-2">
57+
<input
58+
type="color"
59+
id="textColor"
60+
value={`#${textColor || '000000'}`}
61+
onChange={(e) => setTextColor(e.target.value.replace('#', ''))}
62+
className="h-9 w-9 shrink-0 cursor-pointer rounded-md border"
63+
/>
64+
<Input
65+
type="text"
66+
value={textColor}
67+
onChange={(e) => setTextColor(e.target.value.replace('#', ''))}
68+
placeholder="000000"
69+
className="w-full"
70+
/>
71+
</div>
72+
</div>
73+
<div className="grid items-center gap-2">
74+
<Label htmlFor="iconColor">Icon Color</Label>
75+
<div className="flex gap-2">
76+
<input
77+
type="color"
78+
id="iconColor"
79+
value={`#${iconColor || '000000'}`}
80+
onChange={(e) => setIconColor(e.target.value.replace('#', ''))}
81+
className="h-9 w-9 shrink-0 cursor-pointer rounded-md border"
82+
/>
83+
<Input
84+
type="text"
85+
value={iconColor}
86+
onChange={(e) => setIconColor(e.target.value.replace('#', ''))}
87+
placeholder="Icon default"
88+
className="w-full"
89+
/>
90+
</div>
91+
</div>
92+
</div>
93+
<div className="grid w-full items-center gap-2">
94+
<Label>Highlight</Label>
95+
<BadgeHighlightToggle />
96+
</div>
97+
<div className="grid w-full items-center gap-2">
98+
<Label>Selected Icon</Label>
99+
<BadgeSelectedIcon />
100+
</div>
101+
<div className="grid w-full items-center gap-2">
102+
<Label htmlFor="search">Search Icons</Label>
103+
<IconSearch
104+
id="search"
105+
onIconClick={(iconSlug) => setSlug(slug === iconSlug ? '' : iconSlug)}
106+
isSelected={(iconSlug) => slug === iconSlug}
107+
renderOverlay={(selected) =>
108+
selected ? (
109+
<CheckIcon
110+
className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 text-primary"
111+
size={32}
112+
/>
113+
) : null
114+
}
115+
/>
116+
</div>
117+
</Customize>
118+
)
119+
}

0 commit comments

Comments
 (0)