Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
<br>
<br>

[![ci][ci-badge]][ci-url]
[![backers][backers-badge]][backers-url]
[![sponsors][sponsors-badge]][sponsors-url]
[![docker][docker-badge]][docker-url]
</div>

<hr>
Expand Down Expand Up @@ -35,7 +33,11 @@ http://invidget.switchblade.xyz/YOUR_INVITE_CODE_OR_SERVER_ID

### Different language `?language=pt`

![Light theme preview](http://invidget.switchblade.xyz/2FB8wDG?language=pt)
![Portuguese theme preview](http://invidget.switchblade.xyz/2FB8wDG?language=pt)

### Disable animation `?animation=false`

![Dark theme preview with no animation](http://invidget.switchblade.xyz/2FB8wDG?animation=false)

**⚠ THIS PROJECT IS A WIP!**

Expand All @@ -44,7 +46,7 @@ http://invidget.switchblade.xyz/YOUR_INVITE_CODE_OR_SERVER_ID
- Clone this repo
- `npm install`
- Run `npm run dev` to get the development server up
- Access it through http://localhost:8080/
- Access it through http://localhost:8787/

## String guidelines

Expand Down
37 changes: 37 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
{
"name": "invidget-cloudflare-worker",
"version": "0.0.0",
"private": true,
"name": "invidget",
"version": "2.0.0",
"description": "SVG invite widgets that look just like the ones on the Discord client!",
"repository": {
"type": "git",
"url": "git+https://github.com/SwitchbladeBot/invidget.git"
},
"author": "Switchblade Team",
"license": "MIT",
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
Expand All @@ -12,6 +18,7 @@
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.19",
"@types/node": "^24.0.1",
"@types/opentype.js": "^1.3.8",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"typescript": "^5.5.2",
Expand All @@ -21,6 +28,7 @@
"dependencies": {
"discord-api-types": "^0.38.11",
"iso-639-1": "^3.1.5",
"opentype.js": "^1.3.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
Expand Down
72 changes: 43 additions & 29 deletions src/DiscordApi.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
import {
APIGuildWidget,
APIInvite,
} from 'discord-api-types/v10'
import { APIGuildWidget, APIInvite } from 'discord-api-types/v10';

const API_BASE_URL = 'https://discord.com/api/v10'
const CDN_BASE_URL = 'https://cdn.discordapp.com'
const API_BASE_URL = 'https://discord.com/api/v10/';
const CDN_BASE_URL = 'https://cdn.discordapp.com';

export type InvidgetInvite = { iconBase64: string } & APIInvite;

export default class DiscordApi {
static async getWidget(guildId: string): Promise<APIGuildWidget> {
const res = await fetch(`${API_BASE_URL}/guilds/${guildId}/widget.json`)
if (!res.ok) throw new Error(`Failed to fetch widget for guild ${guildId}: ${res.statusText}`)
return res.json()
}

static async getInvite(inviteCode: string): Promise<APIInvite> {
const res = await fetch(`${API_BASE_URL}/invites/${inviteCode}?with_counts=true`)
if (!res.ok) throw new Error(`Failed to fetch invite ${inviteCode}: ${res.statusText}`)
return res.json()
}

static async fetchIcon(iconUrl: string): Promise<string> {
const res = await fetch(iconUrl)
if (!res.ok) throw new Error(`Failed to fetch icon from ${iconUrl}: ${res.statusText}`)
const arrayBuffer = await res.arrayBuffer()
return Buffer.from(arrayBuffer).toString('base64')
}

static getIconUrl(guildId: string, iconId: string): string {
const ext = iconId.startsWith('a_') ? '.gif' : '.jpg'
return `${CDN_BASE_URL}/icons/${guildId}/${iconId}${ext}`
}
static async getWidget(guildId: string): Promise<APIGuildWidget> {
const res = await fetch(`${API_BASE_URL}/guilds/${guildId}/widget.json`);
if (!res.ok) throw new Error(`Failed to fetch widget for guild ${guildId}: ${res.statusText}`);
return res.json();
}

static async getInvite(inviteCode: string, animation: boolean = true): Promise<InvidgetInvite> {
const res = await fetch(`${API_BASE_URL}/invites/${inviteCode}?with_counts=true`);
if (!res.ok) throw new Error(`Failed to fetch invite ${inviteCode}: ${res.statusText}`);

const apiInvite: APIInvite = await res.json();

return {
...apiInvite,
iconBase64:
apiInvite.guild && apiInvite.guild.icon
? await this.fetchIcon(this.getIconUrl(apiInvite.guild.id, apiInvite.guild.icon, animation))
: '',
};
}

static async fetchIcon(iconUrl: string): Promise<string> {
const res = await fetch(iconUrl);
if (!res.ok) throw new Error(`Failed to fetch icon from ${iconUrl}: ${res.statusText}`);

const arrayBuffer = await res.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

static getIconUrl(guildId: string, iconId: string, animation: boolean = true): string {
const ext = iconId.startsWith('a_') && animation ? '.gif' : '.png';
return `${CDN_BASE_URL}/icons/${guildId}/${iconId}${ext}`;
}
}
4 changes: 1 addition & 3 deletions src/InviteResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export default class InviteResolver {
return match[1];
}

const match = query.match(inviteRegex);
if (!match) throw new Error("Invalid invite URL or code.");
return match[1];
return query;
}
}
29 changes: 29 additions & 0 deletions src/components/LegacyTemplate/FeatureBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ReactElement } from 'react';
import { SERVER_NAME_SIZE } from './const';
import { LegacyThemeColors } from '../../types/themes';
import { PARTNER_ICON, SPECIAL_BADGE, VERIFIED_ICON } from '../../const/icons';
export interface BadgeProps {
hasVerified: boolean;
hasHub: boolean;
hasPartnered: boolean;
themeColors: LegacyThemeColors;
}

export const FeatureBadge: React.FC<BadgeProps> = ({ hasVerified, hasHub, hasPartnered, themeColors }) => {
if (hasVerified) {
return (
<g transform={`translate(0, 3)`}>
<path cx={8} cy={8} r={8} d={SPECIAL_BADGE} fill={themeColors.badges.VERIFIED.flowerStar} />
<path cx={8} cy={8} r={8} d={VERIFIED_ICON} fill={themeColors.badges.VERIFIED.icon}/>
</g>
);
} else if (hasPartnered) {
return (
<g transform={`translate(0, 3)`}>
<path cx={8} cy={8} r={8} d={SPECIAL_BADGE} fill={themeColors.badges.PARTNERED.flowerStar}/>
<path cx={8} cy={8} r={8} d={PARTNER_ICON} fill={themeColors.badges.PARTNERED.icon}/>
</g>
);
}
return null;
}
40 changes: 40 additions & 0 deletions src/components/LegacyTemplate/Font/TextToSvgRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import opentype from 'opentype.js';

export type SvgTextOptions = {
fontSize?: number;
x?: number;
y?: number;
fill?: string;
anchor?: 'start' | 'middle' | 'end';
baseline?: 'top' | 'middle' | 'bottom';
};

export function TextToSvgRenderer({
font,
text,
options = {},
...rest
}: { text: string; font: opentype.Font; options?: SvgTextOptions, } & React.SVGProps<SVGPathElement>) {
const fontSize = options.fontSize ?? 72;
let x = options.x ?? 0;
let y = options.y ?? 0;
const fill = options.fill ?? 'black';
const anchor = options.anchor ?? 'start';
const baseline = options.baseline ?? 'bottom';

const path = font.getPath(text, 0, 0, fontSize);
const box = path.getBoundingBox();
const width = box.x2 - box.x1;
const height = box.y2 - box.y1;

if (anchor === 'middle') x -= width / 2;
else if (anchor === 'end') x -= width;

if (baseline === 'middle') y += height / 2;
else if (baseline === 'top') y += height;

const alignedPath = font.getPath(text, x, y, fontSize);
const svgPath = alignedPath.toPathData(2);

return <path d={svgPath} fill={fill} {...rest} />;
}
37 changes: 37 additions & 0 deletions src/components/LegacyTemplate/JoinButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import WhitneyMedium from '../../fonts/WhitneyMedium';
import { LegacyThemeColors } from '../../types/themes';
import { INVITE_WIDTH, PADDING, BUTTON_WIDTH, ICON_SIZE, BUTTON_HEIGHT, PRESENCE_FONT_SIZE } from './const';
import { TextToSvgRenderer } from './Font/TextToSvgRenderer';

type JoinButtonProps = {
themeColors: LegacyThemeColors;
text: string;
};

export default function JoinButton({ themeColors, text }: JoinButtonProps) {
return (
<>
<rect
x={INVITE_WIDTH - PADDING * 2 - BUTTON_WIDTH}
y={(ICON_SIZE - BUTTON_HEIGHT) / 2}
width={BUTTON_WIDTH}
height={BUTTON_HEIGHT}
rx={3}
ry={3}
fill={themeColors.joinButtonBackground}
/>
<TextToSvgRenderer
text={text}
options={{
fontSize: PRESENCE_FONT_SIZE,
fill: themeColors.joinButtonText,
anchor: 'middle',
}}
transform={`translate(${INVITE_WIDTH - PADDING * 2 - BUTTON_WIDTH / 2}, ${
(ICON_SIZE - BUTTON_HEIGHT) / 2 + BUTTON_HEIGHT / 2 + 5
})`}
font={WhitneyMedium}
/>
</>
);
}
Loading