first commit

This commit is contained in:
creations 2025-01-16 11:34:53 -05:00
commit 57562c5ba6
Signed by: creations
GPG key ID: 8F553AA4320FC711
23 changed files with 1979 additions and 0 deletions

179
src/api/status.ts Normal file
View file

@ -0,0 +1,179 @@
import { request as uRequest } from "undici";
import { discordBot } from "../helpers/discord.bot";
import type { FastifyRequest } from "fastify";
import type { Presence } from "oceanic.js";
import type { IRouteInfo } from "../interfaces/routes";
const routeInfo: IRouteInfo = {
enabled: true,
path: "/status",
method: "GET",
websocket: true
};
async function route(socket: WebSocket, _request: FastifyRequest): Promise<void> {
discordBot.addWebSocket(socket);
socket.on("message", async (message: string) => {
try {
const data = JSON.parse(message);
if (data.type === "getStatus") {
let userPresences = Array.from(discordBot.getUserPresences()).reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as Record<string, Presence>);
for (const [userId, presence] of Object.entries(userPresences)) {
if (presence.activities) {
for (let index = 0; index < presence.activities.length; index++) {
const activity = presence.activities[index];
if (activity && activity.type === 0 && (!activity.assets || !activity.assets.largeImage)) {
const gameName = activity.name;
const iconUrl = await getSteamGameIcon(gameName);
if (iconUrl) {
presence.activities[index].assets = {
largeImage: iconUrl,
largeText: gameName
};
}
}
}
}
}
socket.send(JSON.stringify({
type: "statusUpdate",
status: userPresences
}));
} else if (data.type === "getUsers") {
const usersInfo = Array.from(discordBot.getUsersInfo()).reduce((acc, [key, member]) => {
if (member && member.user) {
acc[key] = {
id: member.user.id,
username: member.user.username,
discriminator: member.user.discriminator,
nick: member.nick ?? member.user.username,
avatar: member.user.avatar
? `https://cdn.discordapp.com/avatars/${member.user.id}/${member.user.avatar}`
: `https://cdn.discordapp.com/embed/avatars/${parseInt(member.user.discriminator) % 5}`,
avatarDecoration: member.user.avatarDecoration
? `https://cdn.discordapp.com/avatar-decoration-presets/${member.user.avatarDecoration}`
: null,
banner: member.user.banner
? `https://cdn.discordapp.com/banners/${member.user.id}/${member.user.banner}`
: null,
roles: member.roles,
joinedAt: member.joinedAt,
premiumSince: member.premiumSince,
user: member.user
};
} else {
console.warn(`Invalid member data for key: ${key}`, member);
}
return acc;
}, {} as Record<string, any>);
socket.send(JSON.stringify({
type: "usersUpdate",
users: usersInfo
}));
} else if (data.type === "getBadges" && data.userId) {
let badgesFull: any[] = [];
const vencordLink = "https://badges.vencord.dev/badges.json";
const equicordLink = "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json";
try {
const response = await uRequest(equicordLink);
const text = await response.body.text();
const body: any = JSON.parse(text);
if (!body) return;
const badges = body[data.userId];
if (badges && Array.isArray(badges)) {
badgesFull.push(...badges);
}
} catch (error) {
console.error("Error fetching Equicord badges:", error);
}
try {
const response = await uRequest(vencordLink);
const body: any = await response.body.json();
if (!body) return;
const badges = body[data.userId];
if (badges && Array.isArray(badges)) {
badgesFull.push(...badges);
}
} catch (error) {
console.error("Error fetching Vencord badges:", error);
}
socket.send(JSON.stringify({
type: "badgesUpdate",
badges: badgesFull
}));
}
} catch (error) {
console.error("Error parsing message", error);
}
});
socket.on("close", () => {
discordBot.removeWebSocket(socket);
});
}
let steamAppListCache: { data: any; timestamp: number } | null = null;
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
async function getSteamAppList() {
if (steamAppListCache && (Date.now() - steamAppListCache.timestamp < CACHE_DURATION)) {
return steamAppListCache.data;
}
const response = await fetch(`https://api.steampowered.com/ISteamApps/GetAppList/v2/`);
const data = await response.json();
steamAppListCache = { data, timestamp: Date.now() };
return data;
}
async function getSteamAppID(gameName: string) {
const appList = await getSteamAppList();
const app = appList.applist.apps.find((app: any) => app.name.toLowerCase() === gameName.toLowerCase());
return app ? app.appid : null;
}
export async function getSteamGameIcon(gameName: string) {
const appID = await getSteamAppID(gameName);
if (!appID) {
console.error("Game not found on Steam.");
return;
}
const iconHashResponse = await fetch(`https://store.steampowered.com/api/appdetails?appids=${appID}`);
const iconHashData = await iconHashResponse.json();
const gameData = iconHashData[appID].data;
const iconUrl = gameData.icon
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appID}/${gameData.icon}.jpg`
: gameData.capsule_image || gameData.header_image;
if (iconUrl) {
return iconUrl;
} else {
console.error("Icon not available.");
}
}
export default { routeInfo, route };

176
src/fastify/manager.ts Normal file
View file

@ -0,0 +1,176 @@
import ejs from "ejs";
import Fastify from "fastify";
import { readdir, stat } from "fs/promises";
import { IncomingMessage, Server, ServerResponse } from "http";
import { join } from "path";
import { environment } from "../../config/environment";
// types
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { Stats } from "fs";
import type { AddressInfo } from "net";
// official plugins
import { fastifyStatic } from "@fastify/static";
import { fastifyView } from "@fastify/view";
import fastifyWebSocket from '@fastify/websocket';
//custom plugins
import faviconPlugin from "./plugins/favicon";
class FastifyManager {
private readonly port: number = environment.fastify.port;
private readonly server: FastifyInstance<Server, IncomingMessage, ServerResponse>;
constructor() {
this.server = Fastify({
logger: false,
trustProxy: !environment.development,
ignoreTrailingSlash: true
});
}
private async registerPlugins(): Promise<void> {
// official plugins
this.server.register(fastifyView, {
engine: {
ejs
},
root: environment.paths.www.views,
includeViewExtension: true
});
this.server.register(fastifyStatic, {
root: environment.paths.www.public,
prefix: "/public/",
prefixAvoidTrailingSlash: true
});
await this.server.register(fastifyWebSocket, {
options: {
maxPayload: 1048576
}
});
// custom plugins
this.server.register(faviconPlugin, { path: join(environment.paths.www.public, "assets", "favicon.gif") });
}
// dynamic route loading
private async loadRoutes(): Promise<void> {
const routePaths: [string, string, boolean][] = [
["routes", "/", false],
["../api", "/api", true]
];
for (const [routePath, prefix, recursive] of routePaths) {
const modifiedRoutePath: string = join(environment.paths.www.root, routePath);
let files: string[];
try {
files = await this.readDirRecursive(modifiedRoutePath, recursive);
} catch (err) {
console.error("Failed to read route directory", modifiedRoutePath, "error:", err);
return;
}
for (const file of files) {
try {
const routeModule = await import(file);
const { default: routeData } = routeModule;
if (!routeData || !routeData.routeInfo || !routeData.route) {
console.error(`Failed to load route from ${file}:`, "Route data is missing");
continue;
}
if (routeData.routeInfo.enabled && routeData.route) {
const { routeInfo, route } = routeData;
let routePath = routeInfo.path || "/";
if (prefix) {
routePath = routePath === "/" ? prefix : join(prefix, routePath);
}
routePath = routePath.replace(/\\/g, '/');
if (!routePath.startsWith('/')) {
routePath = '/' + routePath;
}
routePath = routePath.replace(/\/+/g, "/");
const methods = Array.isArray(routeInfo.method) ? routeInfo.method : [routeInfo.method];
for (const method of methods) {
if (routeInfo.websocket) {
this.server.get(routePath, { websocket: true }, function wsHandler(socket: any, req: FastifyRequest) {
route(socket, req);
});
continue;
} else {
this.server.route({
method,
url: routePath,
handler: route
});
}
}
}
} catch (err) {
console.error(`Failed to load route from ${file}:`, err);
}
}
}
}
private async readDirRecursive(dir: string, recursive: boolean): Promise<string[]> {
let results: string[] = [];
const list: string[] = await readdir(dir);
for (const file of list) {
const filePath: string = join(dir, file);
const statObj: Stats = await stat(filePath);
if (statObj && statObj.isDirectory()) {
if (recursive) {
results = results.concat(await this.readDirRecursive(filePath, recursive));
}
} else {
results.push(filePath);
}
}
return results;
}
public async start(): Promise<void> {
try {
await this.registerPlugins();
await this.loadRoutes();
await this.server.listen({
port: environment.fastify.port,
host: environment.fastify.host
});
const [_address, _port, scheme]: [string, number, string] = ((): [string, number, string] => {
const address: string | AddressInfo | null = this.server.server.address();
const resolvedAddress: [string, number] = (address && typeof address === 'object') ? [(address.address.startsWith("::") || address.address === "0.0.0.0") ? "localhost" : address.address, address.port] : ["localhost", this.port];
const resolvedScheme: string = resolvedAddress[0] === "localhost" ? "http" : "https";
return [...resolvedAddress, resolvedScheme];
})();
console.info("Started Listening on", `${scheme}://${_address}:${_port}`);
console.info("Registered routes: ", "\n", this.server.printRoutes());
} catch (error) {
console.error("Failed to start Fastify server", error);
}
}
}
const fastifyManager = new FastifyManager();
export default fastifyManager;
export { fastifyManager };

View file

@ -0,0 +1,35 @@
import type { FastifyInstance, FastifyPluginAsync } from "fastify";
import type { FastifyReply } from "fastify/types/reply";
import type { FastifyRequest } from "fastify/types/request";
import { readFile } from "fs/promises";
interface FaviconOptions {
path: string;
}
const faviconPlugin: FastifyPluginAsync<FaviconOptions> = async (fastify: FastifyInstance, options: FaviconOptions): Promise<void> => {
let faviconData: Buffer | null = null;
try {
faviconData = await readFile(options.path);
} catch (err) {
console.error("Error reading favicon:", err);
}
fastify.get("/favicon.ico", async (_request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (faviconData) {
const contentType = options.path.endsWith(".gif") ? "image/gif" : "image/x-icon";
reply.header("Content-Type", contentType)
.header("Cache-Control", "public, max-age=86400") // 1 day
.send(faviconData);
} else {
reply.status(404).send({
code: 404,
error: "FILE_NOT_FOUND",
message: "Favicon not found"
});
}
});
};
export default faviconPlugin as FastifyPluginAsync<FaviconOptions>;

212
src/helpers/discord.bot.ts Normal file
View file

@ -0,0 +1,212 @@
import { Client, Guild, Member, type JSONMember, type Presence, type Uncached } from "oceanic.js";
import { environment } from "../../config/environment";
import { getSteamGameIcon } from "../api/status";
class DiscordBot {
private client: Client;
private watchIds: string[] = environment.discord.watchIds.map((id) => id.toString());
private userPresences: Map<string, Presence>;
private connectedSockets: Set<WebSocket>;
private usersInfo: Map<string, any>;
constructor() {
this.client = new Client({
auth: `Bot ${environment.discord.auth.token}`,
gateway: {
intents: ["GUILDS", "GUILD_PRESENCES"]
}
});
this.userPresences = new Map();
this.usersInfo = new Map();
this.connectedSockets = new Set();
this.watchIds = environment.discord.watchIds.map((id) => id.toString());
}
public async start(): Promise<void> {
try {
await this.client.connect();
await this.setupListeners();
} catch (error) {
console.error("Failed to connect to Discord: ", error);
}
}
public async stop(): Promise<void> {
try {
this.client.disconnect();
} catch (error) {
console.error("Failed to disconnect from Discord: ", error);
}
}
public getClient(): Client {
return this.client;
}
public getUserPresences(): Map<string, Presence> {
return this.userPresences;
}
public getUsersInfo(): Map<string, any> {
return this.usersInfo;
}
public addWebSocket(socket: WebSocket): void {
this.connectedSockets.add(socket);
}
public removeWebSocket(socket: WebSocket): void {
this.connectedSockets.delete(socket);
}
private async broadcastPresenceUpdate(userId: string, presence: Presence): Promise<void> {
const updatedActivities = presence.activities
? await Promise.all(
presence.activities.map(async (activity) => {
if (activity && activity.type === 0 && (!activity.assets || !activity.assets.largeImage)) {
const gameName = activity.name;
// should use what discord uses but i dont care enough to switch it out
const iconUrl = await getSteamGameIcon(gameName);
if (iconUrl) {
return {
...activity,
assets: {
largeImage: iconUrl,
largeText: gameName
}
};
}
}
return activity;
})
)
: [];
const updateMessage = JSON.stringify({
type: "presenceUpdate",
userId,
presence: {
status: presence.status,
activities: updatedActivities
}
});
this.broadcastToWebSockets(updateMessage);
}
private async fetchUsersInfo(): Promise<void> {
const guild = this.client.guilds.get(environment.discord.auth.guildId);
if (!guild) return;
for (const id of this.watchIds) {
try {
const member = await guild.getMember(id);
if (member) {
const memberData = this.serializeMember(member);
this.usersInfo.set(member.user.id, memberData);
const updateMessage = JSON.stringify({
type: "memberUpdate",
user: memberData
});
this.broadcastToWebSockets(updateMessage);
}
} catch (error) {
console.error(`Failed to fetch member with ID ${id}:`, error);
}
}
}
private async fetchUserPresences(): Promise<void> {
const guild = this.client.guilds.get(environment.discord.auth.guildId);
if (!guild) return;
this.watchIds.forEach((id) => {
const member = guild.members.get(id);
if (member && member.presence) {
this.userPresences.set(member.user.id, member.presence);
}
});
}
private async setupListeners(): Promise<void> {
this.client.on("ready", async () => {
const user = this.client.user;
const guild = this.client.guilds.get(environment.discord.auth.guildId);
if (user && guild) {
await this.fetchUserPresences();
await this.fetchUsersInfo();
}
});
this.client.on("presenceUpdate", (guild: Guild | Uncached, member: Member | Uncached, presence: Presence, oldPresence: Presence | null) => {
if (!(member instanceof Member)) return;
const userId = member.user.id.toString();
if (!this.watchIds.includes(userId)) return;
const oldStatus = oldPresence;
const newStatus = presence;
if (oldStatus !== newStatus) {
this.userPresences.set(userId, presence);
this.broadcastPresenceUpdate(userId, presence);
}
});
this.client.on("guildMemberUpdate", (member: Member, oldMember: JSONMember | null) => {
const userId = member.user.id.toString();
if (!this.watchIds.includes(userId)) return;
const memberData = this.serializeMember(member);
this.usersInfo.set(userId, memberData);
const updateMessage = JSON.stringify({
type: "memberUpdate",
user: memberData
});
this.broadcastToWebSockets(updateMessage);
});
}
private serializeMember(member: Member): object {
const user = member.user;
return {
id: user.id,
username: user.username,
discriminator: user.discriminator,
nick: member.nick ?? user.username,
avatar: user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}`
: `https://cdn.discordapp.com/embed/avatars/${parseInt(user.discriminator) % 5}`,
avatarDecoration: user.avatarDecorationData?.asset
? `https://cdn.discordapp.com/avatar-decoration-presets/${user.avatarDecorationData.asset}`
: null,
roles: member.roles,
joinedAt: member.joinedAt,
premiumSince: member.premiumSince,
user: user
};
}
private broadcastToWebSockets(message: string): void {
for (const socket of this.connectedSockets) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(message);
}
}
}
}
const discordBot = new DiscordBot();
export { discordBot };
export default discordBot;

16
src/index.ts Normal file
View file

@ -0,0 +1,16 @@
import { fastifyManager } from "./fastify/manager";
import { discordBot } from "./helpers/discord.bot";
class Index {
public static async main() : Promise<void> {
const main = new Index();
await main.start();
};
public async start() : Promise<void> {
await discordBot.start();
await fastifyManager.start();
}
}
const index : Promise<void> = Index.main();

View file

@ -0,0 +1,25 @@
export interface IEnvironment {
development: boolean;
fastify: {
host: string;
port: number;
};
discord: {
auth: {
token: string;
guildId: string;
};
watchIds: bigint[];
};
paths: {
src: string;
www: {
root: string;
views: string;
public: string;
}
};
}

8
src/interfaces/routes.ts Normal file
View file

@ -0,0 +1,8 @@
import type { TMethod } from "../types/routes";
export interface IRouteInfo {
enabled: boolean;
path: string;
method: TMethod | TMethod[];
websocket?: boolean;
}

1
src/types/routes.ts Normal file
View file

@ -0,0 +1 @@
export type TMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD";

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#43b581" fill-rule="evenodd" d="M20.97 4.06c0 .18.08.35.24.43.55.28.9.82 1.04 1.42.3 1.24.75 3.7.75 7.09v4.91a3.09 3.09 0 0 1-5.85 1.38l-1.76-3.51a1.09 1.09 0 0 0-1.23-.55c-.57.13-1.36.27-2.16.27s-1.6-.14-2.16-.27c-.49-.11-1 .1-1.23.55l-1.76 3.51A3.09 3.09 0 0 1 1 17.91V13c0-3.38.46-5.85.75-7.1.15-.6.49-1.13 1.04-1.4a.47.47 0 0 0 .24-.44c0-.7.48-1.32 1.2-1.47l2.93-.62c.5-.1 1 .06 1.36.4.35.34.78.71 1.28.68a42.4 42.4 0 0 1 4.4 0c.5.03.93-.34 1.28-.69.35-.33.86-.5 1.36-.39l2.94.62c.7.15 1.19.78 1.19 1.47ZM20 7.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM15.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM5 7a1 1 0 0 1 2 0v1h1a1 1 0 0 1 0 2H7v1a1 1 0 1 1-2 0v-1H4a1 1 0 1 1 0-2h1V7Z" clip-rule="evenodd" class=""></path>
</svg>

After

Width:  |  Height:  |  Size: 799 B

View file

@ -0,0 +1,37 @@
@font-face {
font-family: 'FantasqueSansMNerdFont';
src: url('/public/assets/fonts/FantasqueSansMNerdFont-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
[data-theme="light"] {
--background-color: rgb(255, 255, 255);
--text-color: #333;
}
[data-theme="dark"] {
--background-color: rgb(0, 0, 0);
--text-color: #f5f5f5;
}
html {
background-color: var(--background-color);
color: var(--text-color);
}
body {
margin: 0;
padding: 0;
font-family: 'FantasqueSansMNerdFont', sans-serif;
font-size: 16px;
}
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}

View file

@ -0,0 +1,413 @@
#user-container {
display: flex;
justify-content: center;
align-items: center;
z-index: -1;
}
#user-info {
text-align: center;
width: 100%;
max-width: 580px;
margin: 0 0 0 0;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
background-color: rgb(17, 17, 19);
margin: 5em 0 2em 0;
border: 3px solid rgb(52, 52, 57);
border-radius: 8px;
}
.avatar-container {
position: relative;
width: 128px;
height: 128px;
display: inline-block;
margin-top: 110px;
background-color: rgb(17, 17, 19);
border-radius: 50%;
}
#avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 6px solid rgb(17, 17, 19);
}
#avatar-decoration {
position: absolute;
top: -7px;
left: -7px;
width: 120%;
height: 120%;
object-fit: cover;
pointer-events: none;
}
#display-name {
margin: 0 0px -5px 0;
color: white;
font-size: 25px;
font-weight: 800;
font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
#name-pronouns-badges {
display: flex;
justify-content: left;
align-items: flex-start;
flex-direction: column;
margin: 0 0 0 0;
}
#pronouns-badges {
display: flex;
align-items: flex-end;
flex-direction: row;
gap: 10px;
}
#name-pronouns {
margin: 0 0 2px 0;
color: white;
font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
text-align: end;
}
.status-circle {
position: absolute;
bottom: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 6px solid #202225;
}
.online {
background-color: #43b581;
}
.idle {
background-color: #faa61a;
}
.dnd {
background-color: #f04747;
}
.offline {
background-color: #747f8d;
}
#activity-list {
margin-top: 10px;
width: 100%;
box-sizing: border-box;
background-color: transparent;
box-sizing: border-box;
}
#activity-list.no-activity {
display: none;
}
.activity {
display: flex;
flex-direction: column;
background-color: rgb(6, 6, 6);
border-radius: 8px;
margin-bottom: 10px;
position: relative;
width: 100%;
padding: 10px;
box-sizing: border-box;
}
.activity:last-child {
margin-bottom: 0;
}
.activity-type-container {
width: 100%;
text-align: left;
margin-bottom: 5px;
}
.activity-content {
display: flex;
align-items: flex-start;
width: 100%;
}
.large-image {
position: relative;
display: inline-block;
margin-right: 15px;
}
.asset-large-image {
width: 60px;
height: 60px;
border-radius: 4px;
}
.asset-small-image {
position: absolute;
bottom: -5px;
right: -5px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgb(17, 17, 19);
}
.activity-info {
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
align-items: flex-start;
}
.activity-type,
.activity-info .activity-name {
font-size: 16px;
color: #fff;
margin: 0;
font-weight: 500;
}
.activity-type {
margin-bottom: 2px;
font-size: 12px;
color: #b9bbbe;
}
.activity-info .activity-state,
.activity-info .activity-large-text,
.activity-info .activity-details {
color: #b9bbbe;
font-size: 14px;
margin: 0 0 1px 0;
}
.activity-info .activity-timestamp {
color: #43b581;
font-size: 12px;
display: flex;
align-items: center;
margin: 5px 0 0 0;
}
.activity-info .activity-timestamp img {
margin-right: 5px;
width: 14px;
height: 14px;
}
.avatar-custom-container {
position: relative;
width: 100%;
height: 100%;
display: inline-block;
display: flex;
margin: 0 0 2em 0;
}
.custom-status-text {
background-color: rgb(7, 12, 23);
border-radius: 12px;
padding: 8px 0 5px 15px;
font-size: 14px;
color: #fff;
max-width: 200px;
line-height: 1.2;
position: absolute;
outline: 1px solid #2b2e35;
left: 160px;
top: 76%;
text-align: left;
}
.custom-status-text::before {
content: "";
position: absolute;
top: -11px;
left: 10px;
width: 20px;
height: 10px;
background-color: rgb(7, 12, 23);
border-radius: 10px 10px 0 0;
border: 1px solid #2b2e35;
border-bottom: none;
}
.custom-status-text::after {
content: "";
position: absolute;
top: -18px;
left: -2px;
width: 12px;
height: 12px;
background-color: rgb(7, 12, 23);
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transform: translateY(-50%);
border: 1px solid #2b2e35;
}
#badge-container {
display: flex;
justify-content: space-between;
gap: 5px;
background-color: rgb(17, 17, 19);
border-radius: 4px;
border: 1px solid #2b2e35;
padding: 2px;
font-size: 14px;
align-items: center;
}
.badge {
width: 20px;
height: 20px;
border-radius: 50%;
}
#banner {
width: calc(100% + 40px);
height: 210px;
object-fit: fill;
position: absolute;
top: -20px;
left: -20px;
z-index: -1;
padding: 0;
border-radius: 5px 5px 0 0;
}
#user-tabs-container {
margin-top: 15px;
padding: 15px;
box-sizing: border-box;
border: 1px solid rgb(45, 45, 48);
border-radius: 8px;
background-color: rgb(10, 10, 10);
}
#user-tabs-buttons {
display: flex;
justify-content: flex-start;
gap: 20px;
margin: 0 0 10px 0;
padding: 0;
}
#user-tabs-buttons button {
background-color: transparent;
color: #fff;
border: none;
padding: 5px 10px;
font-size: 14px;
cursor: pointer;
padding: 0;
}
#user-tabs-buttons button.active {
text-decoration: underline;
text-underline-position: under;
text-underline-offset: 9px;
text-decoration-thickness: 1px;
}
.tab-content.hidden {
display: none;
}
.tab-content.active {
display: flex;
}
.tab-content {
border-top: .5px solid rgb(45, 45, 48);
padding-top: 10px;
}
#about-me-content {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
#about-me-content a {
text-decoration: none;
margin-top: 10px;
}
#about-me-content a:hover {
text-decoration: underline;
text-decoration-thickness: 2px;
}
#about-me-content a:visited {
color: rgb(59, 131, 194);
}
#about-me-content #socials {
display: flex;
flex-direction: column;
gap: 15px;
margin-top: 20px;
align-items: flex-start;
border-radius: 10px;
}
#about-me-content #socials h3 {
margin: 0;
color: #ffffff;
font-size: 1.5em;
font-weight: bold;
}
#about-me-content #socials ul {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
text-align: center;
}
#about-me-content #socials ul li {
margin: 0;
align-items: center;
text-align: left;
display: flex;
gap: 10px;
padding: 0;
}
#about-me-content #socials ul li img {
width: 30px;
height: 30px;
}
#about-me-content #socials ul li a {
padding: 0;
margin: 0;
align-self: center;
}
#about-me-content #socials ul li a.visited {
color: rgb(59, 131, 194) !important;
}

80
src/www/public/js/snow.js Normal file
View file

@ -0,0 +1,80 @@
document.addEventListener('DOMContentLoaded', function () {
const snowContainer = document.createElement('div');
snowContainer.style.position = 'fixed';
snowContainer.style.top = '0';
snowContainer.style.left = '0';
snowContainer.style.width = '100vw';
snowContainer.style.height = '100vh';
snowContainer.style.pointerEvents = 'none';
document.body.appendChild(snowContainer);
const maxSnowflakes = 60;
const snowflakes = [];
const mouse = { x: -100, y: -100 };
document.addEventListener('mousemove', function (e) {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
const createSnowflake = () => {
if (snowflakes.length >= maxSnowflakes) {
const oldestSnowflake = snowflakes.shift();
snowContainer.removeChild(oldestSnowflake);
}
const snowflake = document.createElement('div');
snowflake.classList.add('snowflake');
snowflake.style.position = 'absolute';
snowflake.style.width = `${Math.random() * 3 + 2}px`;
snowflake.style.height = snowflake.style.width;
snowflake.style.background = 'white';
snowflake.style.borderRadius = '50%';
snowflake.style.opacity = Math.random();
snowflake.style.left = `${Math.random() * window.innerWidth}px`;
snowflake.style.top = `-${snowflake.style.height}`;
snowflake.speed = Math.random() * 3 + 2;
snowflake.directionX = (Math.random() - 0.5) * 0.5;
snowflake.directionY = Math.random() * 0.5 + 0.5;
snowflakes.push(snowflake);
snowContainer.appendChild(snowflake);
};
setInterval(createSnowflake, 80);
function updateSnowflakes() {
snowflakes.forEach((snowflake, index) => {
let rect = snowflake.getBoundingClientRect();
let dx = rect.left + rect.width / 2 - mouse.x;
let dy = rect.top + rect.height / 2 - mouse.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 30) {
snowflake.directionX += (dx / distance) * 0.02;
snowflake.directionY += (dy / distance) * 0.02;
} else {
snowflake.directionX += (Math.random() - 0.5) * 0.01;
snowflake.directionY += (Math.random() - 0.5) * 0.01;
}
snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`;
snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`;
if (rect.top + rect.height >= window.innerHeight) {
snowContainer.removeChild(snowflake);
snowflakes.splice(index, 1);
}
if (rect.left > window.innerWidth || rect.top > window.innerHeight || rect.left < 0) {
snowflake.style.left = `${Math.random() * window.innerWidth}px`;
snowflake.style.top = `-${snowflake.style.height}`;
}
});
requestAnimationFrame(updateSnowflakes);
}
updateSnowflakes();
});

View file

@ -0,0 +1,397 @@
const ws = new WebSocket(`/api/status`);
document.addEventListener("DOMContentLoaded", () => {
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ type: "getStatus" }));
ws.send(JSON.stringify({ type: "getUsers" }));
});
ws.addEventListener("message", (event) => {
handleMessage(event);
});
ws.addEventListener("close", () => {
// console.log("WebSocket connection closed.");
});
ws.addEventListener("error", (error) => {
console.error("WebSocket error:", error);
});
const bannerElem = document.getElementById("banner");
const avatar_custom_container = document.getElementById("avatar-custom-container",);
const avatarElem = document.getElementById("avatar");
const displayNameElem = document.getElementById("display-name");
const decorationElem = document.getElementById("avatar-decoration");
const usernameElem = document.getElementById("username");
const statusCircle = document.getElementById("status-circle");
const badgeContainer = document.getElementById("badge-container");
const about_me_tab = document.getElementById("about-me-content");
const about_me_tab_button = document.getElementById("about-me");
const activity_tab = document.getElementById("activity-content");
const activity_tab_button = document.getElementById("activity");
// const placeholderAvatar = "/public/images/avatar-placeholder.png";
const placeholderUsername = "Loading...";
// avatarElem.src = placeholderAvatar;
usernameElem.textContent = placeholderUsername;
decorationElem.style.display = "none";
function handleMessage(event) {
const data = JSON.parse(event.data);
if (data.type === "statusUpdate") {
handleStatusUpdate(data.status);
} else if (data.type === "presenceUpdate") {
handlePresenceUpdate(data.presence);
} else if (data.type === "usersUpdate") {
handleUsersUpdate(data.users);
} else if (data.type === "badgesUpdate") {
handleBadges(data.badges);
}
}
function updateStatusCircle(status) {
if (!statusCircle || !status) return;
statusCircle.classList.remove("online", "idle", "dnd", "offline");
statusCircle.classList.add(status);
}
function typeText(type) {
switch (type) {
case 0:
return "Playing";
case 1:
return "Streaming";
case 2:
return "Listening to";
case 3:
return "Watching";
case 4:
return "Custom Status";
default:
return "Unknown";
}
}
const tabButtons = document.querySelectorAll(".tab-button");
tabButtons.forEach((button) => {
const type = button.getAttribute("data-type");
button.addEventListener("click", () => {
tabSwitch(type);
});
});
function tabSwitch(tab) {
if (tab === "activity") {
about_me_tab.classList.remove("active");
about_me_tab_button.classList.remove("active");
about_me_tab.classList.add("hidden");
activity_tab.classList.add("active");
activity_tab_button.classList.add("active");
activity_tab.classList.remove("hidden");
} else {
activity_tab.classList.remove("active");
activity_tab_button.classList.remove("active");
activity_tab.classList.add("hidden");
about_me_tab.classList.add("active");
about_me_tab_button.classList.add("active");
about_me_tab.classList.remove("hidden");
}
}
function updateActivities(activities) {
const activityList = document.createElement("div");
const customStatusText = document.getElementById("custom-status-text");
activityList.id = "activity-list";
const shouldShowNoActivity = activities.length === 0 || (activities.length === 1 && activities[0]?.type === 4);
if (shouldShowNoActivity) {
const noActivity = document.createElement("p");
noActivity.classList.add("no-activity");
noActivity.textContent = "No activity(s) to display";
activityList.appendChild(noActivity);
tabSwitch("about-me");
}
const existingActivities = document.getElementById("activity-list");
if (existingActivities) existingActivities.remove();
if (customStatusText) customStatusText.remove();
activities.forEach((activity) => {
if (activity.type === 4) {
const customStatusDiv = document.createElement("div");
customStatusDiv.classList.add("custom-status");
const customStatus = document.createElement("p");
customStatus.classList.add("custom-status-text");
customStatus.id = "custom-status-text";
customStatus.textContent = activity.state;
customStatusDiv.appendChild(customStatus);
avatar_custom_container.appendChild(customStatusDiv);
return;
}
const activityDiv = document.createElement("div");
activityDiv.classList.add("activity");
let activityLargeText;
const activityTypeDiv = document.createElement("div");
activityTypeDiv.classList.add("activity-type-container");
const activityType = document.createElement("p");
activityType.classList.add("activity-type");
if (activity.type === 2) {
activityType.textContent = `${typeText(activity.type)} ${activity.name}`;
} else {
activityType.textContent = `${typeText(activity.type)}`;
}
activityTypeDiv.appendChild(activityType);
activityDiv.appendChild(activityTypeDiv);
const activityContentDiv = document.createElement("div");
activityContentDiv.classList.add("activity-content");
if (activity.assets?.largeImage) {
const largeImageDiv = document.createElement("div");
largeImageDiv.classList.add("large-image");
if (activity.assets.largeText && activity.type === 2)
activityLargeText = activity.assets.largeText;
const largeImage = document.createElement("img");
largeImage.src = parseDiscordImage(
activity.assets.largeImage,
activity.applicationID,
);
largeImage.alt = "Large Image";
largeImage.classList.add("asset-large-image");
largeImageDiv.appendChild(largeImage);
if (activity.assets?.smallImage) {
const smallImageDiv = document.createElement("div");
smallImageDiv.classList.add("small-image");
const smallImage = document.createElement("img");
smallImage.src = parseDiscordImage(
activity.assets.smallImage,
activity.applicationID,
);
smallImage.alt = "Small Image";
smallImage.classList.add("asset-small-image");
smallImageDiv.appendChild(smallImage);
largeImageDiv.appendChild(smallImageDiv);
}
activityContentDiv.appendChild(largeImageDiv);
}
const activityInfoDiv = document.createElement("div");
activityInfoDiv.classList.add("activity-info");
const name = document.createElement("p");
name.classList.add("activity-name");
if (activity.type === 2) {
name.textContent = `${activity.details}`;
} else {
name.textContent = `${activity.name}`;
}
activityInfoDiv.appendChild(name);
if (activity.details && activity.type !== 2) {
const details = document.createElement("p");
details.classList.add("activity-details");
details.textContent = `${activity.details}`;
activityInfoDiv.appendChild(details);
}
if (activity.state) {
const state = document.createElement("p");
state.classList.add("activity-state");
state.textContent = `${activity.state}`;
activityInfoDiv.appendChild(state);
}
if (activityLargeText) {
const largeText = document.createElement("p");
largeText.classList.add("activity-large-text");
largeText.textContent = `${activityLargeText}`;
activityInfoDiv.appendChild(largeText);
}
if (activity.timestamps?.start) {
const timestamp = document.createElement("p");
timestamp.classList.add("activity-timestamp");
const startTime = activity.timestamps.start;
function updateElapsedTime() {
const elapsedTime = getElapsedTime(startTime);
timestamp.innerHTML = `<img src="/public/assets/game_icon.svg" alt="Time icon"> ${elapsedTime}`;
}
updateElapsedTime();
setInterval(updateElapsedTime, 1000);
activityInfoDiv.appendChild(timestamp);
}
activityContentDiv.appendChild(activityInfoDiv);
activityDiv.appendChild(activityContentDiv);
activityList.appendChild(activityDiv);
});
activity_tab.appendChild(activityList);
}
function getElapsedTime(startTimestamp) {
const now = Date.now();
const elapsed = now - startTimestamp;
const seconds = Math.floor(elapsed / 1000) % 60;
const minutes = Math.floor(elapsed / (1000 * 60)) % 60;
const hours = Math.floor(elapsed / (1000 * 60 * 60)) % 24;
const days = Math.floor(elapsed / (1000 * 60 * 60 * 24));
let formattedTime = "";
if (days > 0) {
formattedTime += `${days}:`;
}
if (hours > 0 || days > 0) {
formattedTime += `${pad(hours)}:`;
}
if (minutes > 0 || hours > 0 || days > 0) {
formattedTime += `${pad(minutes)}:`;
}
formattedTime += `${pad(seconds)}`;
return formattedTime;
}
function pad(value) {
return String(value).padStart(2, "0");
}
function handleStatusUpdate(status) {
const firstUser = Object.values(status)[0];
const userStatus = firstUser?.status;
const activities = firstUser?.activities || [];
updateStatusCircle(userStatus);
updateActivities(activities);
}
function handlePresenceUpdate(presence) {
const userPresence = presence?.status;
const activities = presence?.activities || [];
updateStatusCircle(userPresence);
updateActivities(activities);
}
function parseDiscordImage(imageUrl, activityID) {
if (imageUrl.startsWith("mp:external/")) {
const httpsIndex = imageUrl.indexOf("https/");
if (httpsIndex !== -1) {
return "https://" + imageUrl.slice(httpsIndex + "https/".length);
}
} else if (/^\d+$/.test(imageUrl)) {
return `https://cdn.discordapp.com/app-assets/${activityID}/${imageUrl}`;
}
return imageUrl;
}
function handleUsersUpdate(users) {
const firstUser = Object.values(users)[0];
ws.send(JSON.stringify({ type: "getBadges", userId: firstUser.id }));
if (!firstUser || !firstUser.user) return;
const { avatar, avatarDecoration, username, banner } = firstUser;
const displayName = firstUser.user.globalName || firstUser.user.username;
updateUserInfo(avatar, avatarDecoration, username, displayName, banner);
}
function updateUserInfo(
avatarUrl,
decorationUrl,
username,
displayName,
banner,
) {
if (bannerElem) {
bannerElem.src = banner + "?size=2048" || "";
}
if (avatarElem) {
avatarElem.src = avatarUrl; //|| placeholderAvatar;
}
if (decorationElem) {
if (decorationUrl) {
decorationElem.src = decorationUrl;
decorationElem.style.display = "block";
} else {
decorationElem.style.display = "none";
}
}
if (usernameElem) {
usernameElem.textContent = username || placeholderUsername;
}
if (displayNameElem) {
displayNameElem.textContent = displayName || placeholderUsername;
}
}
function handleBadges(badges) {
// known as
badges.push({
badge: "https://cdn.discordapp.com/badge-icons/6de6d34650760ba5551a79732e98ed60.png",
tooltip: "Originally known as Creation's#0001",
});
//hypesquad
badges.push({
badge: "https://cdn.discordapp.com/badge-icons/3aa41de486fa12454c3761e8e223442e.png",
tooltip: "HypeSquad Balance",
});
badges.push({
badge: "https://cdn.discordapp.com/badge-icons/6bdc42827a38498929a4920da12695d9.png",
tooltip: "Active Developer",
});
for (const badge of badges) {
if ((badge && !badge.badge) || !badge.tooltip) continue;
const badgeImg = document.createElement("img");
badgeImg.src = badge.badge;
badgeImg.alt = badge.tooltip;
badgeImg.title = badge.tooltip;
badgeImg.classList.add("badge");
badgeContainer.appendChild(badgeImg);
}
}
});

20
src/www/routes/index.ts Normal file
View file

@ -0,0 +1,20 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import type { IRouteInfo } from "../../interfaces/routes";
const routeInfo: IRouteInfo = {
enabled: true,
path: "/",
method: "GET"
};
async function route(request : FastifyRequest, reply : FastifyReply) : Promise<string> {
const siteData = {
title: "Home",
};
return reply.viewAsync("index", {
...siteData,
});
}
export default { routeInfo, route }

74
src/www/views/index.ejs Normal file
View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>creations.works</title>
<link rel="stylesheet" href="/public/css/style.css" />
<link rel="stylesheet" href="/public/css/user.style.css" />
<script src="/public/js/status.ws.js" defer></script>
<script src="/public/js/snow.js" defer></script>
</head>
<body>
<div id="page-views"></div>
<div id="user-container">
<div id="user-info">
<div class="avatar-custom-container" id="avatar-custom-container">
<img id="banner" />
<div class="avatar-container">
<img id="avatar" />
<img id="avatar-decoration" src="" />
<div id="status-circle" class="status-circle offline"></div>
</div>
</div>
<div id="name-pronouns-badges">
<h2 id="display-name">User Name</h2>
<div id="pronouns-badges">
<div id="name-pronouns">
<span id="username">User Name</span>
<span id="dot">•</span>
<span id="pronouns">creations.works</span>
</div>
<div id="badge-container"></div>
</div>
</div>
<div id="user-tabs-container">
<div id="user-tabs-buttons">
<button id="about-me" class="tab-button" data-type='about-me'>About Me</button>
<button id="activity" class="tab-button active" data-type='activity'>Activity</button>
</div>
<div id="tab-content">
<div id="about-me-content" class="tab-content hidden">
<a href="https://atums.world" target="_blank">https://atums.world</a>
<div id="socials">
<h3>Find me here</h3>
<ul>
<li>
<img src="https://external-content.duckduckgo.com/iu/?u=http%3A%2F%2Fsudhop.com%2Fbilder%2Fmail.png&f=1&nofb=1&ipt=1ae00a3202725283cc556e3feefdb72750c50afa5a44f428ea3c15d7babf1d70&ipo=images" />
<a href="mailto:creations@creations.works">Email, creations@creations.works</a>
</li>
<li>
<img src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/636e0a69f118df70ad7828d4_icon_clyde_blurple_RGB.svg" />
<a href="https://discord.gg/DxFhhbr2pm" target="_blank">Discord</a>
</li>
<li>
<img src="https://git.creations.works/assets/img/logo.svg" alt="Forgejo Icon">
<a href="https://git.creations.works/creations" target="_blank">Forgejo</a>
</li>
<li>
<img src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fportalrepository.com%2Fwp-content%2Fuploads%2F2019%2F07%2F1024px-Steam_icon_logo.svg.png&f=1&nofb=1&ipt=42fde64bfc0c2aa3cab5944e37a1494d95b368514950c98317460883a1a94bf6&ipo=images" />
<a href="https://steamcommunity.com/id/creations_works/" target="_blank">Steam</a>
</ul>
</div>
</div>
<div id="activity-content" class="tab-content active"></div>
</div>
</div>
</div>
</body>
</html>