commit 57562c5ba6753ac4d83f622870ea3f4783566148 Author: creations <creations@creations.works> Date: Thu Jan 16 11:34:53 2025 -0500 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b2d08f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# NODE_ENV=development +HOST=0.0.0.0 +PORT=8080 + +DISCORD_TOKEN=YOUR_DISCORD_BOT_TOKEN +DISCORD_GUILD_ID=YOUR_DISCORD_GUILD_ID +DISCORD_USER_ID=YOUR_DISCORD_USER_ID \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c308f77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,179 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of "npm pack" + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +/.vscode +bun.lockb +pnpm-lock.yaml +yarn.lock diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..94d3a0d --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Creation's // [creations@creations.works] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/config/environment.ts b/config/environment.ts new file mode 100644 index 0000000..170acb1 --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,43 @@ +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import type{ IEnvironment } from "../src/interfaces/environment"; + +const __dirname : string = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const environment : IEnvironment = { + development: process.env.NODE_ENV === "development" || process.argv.includes("--dev"), + + fastify: { + host: process.env.HOST || "0.0.0.0", + port: parseInt(process.env.PORT || "8080"), + }, + + discord: { + auth: { + token: process.env.DISCORD_TOKEN || "", + guildId: process.env.DISCORD_GUILD_ID || "", + }, + watchIds: [ + (() => { + const id = process.env.DISCORD_USER_ID; + if (id && /^\d+$/.test(id)) { + return BigInt(id); + } + return BigInt(0); + })() + ], + }, + + paths: { + src: __dirname, + www: { + root: join(__dirname, "src", "www"), + views: join(__dirname, "src", "www", "views"), + public: join(__dirname, "src", "www", "public") + } + } +}; + +export default environment; +export { environment }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..43c558f --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "ts_fastify_example", + "module": "src/index.ts", + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts --development", + "prod": "bun run src/index.ts" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/ejs": "latest" + }, + "peerDependencies": { + "typescript": "latest" + }, + "dependencies": { + "@fastify/static": "latest", + "@fastify/view": "latest", + "@fastify/websocket": "latest", + "ejs": "latest", + "fastify": "latest", + "oceanic.js": "latest", + "undici": "^6.20.1" + } +} diff --git a/src/api/status.ts b/src/api/status.ts new file mode 100644 index 0000000..01e7e4c --- /dev/null +++ b/src/api/status.ts @@ -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 }; \ No newline at end of file diff --git a/src/fastify/manager.ts b/src/fastify/manager.ts new file mode 100644 index 0000000..8124c62 --- /dev/null +++ b/src/fastify/manager.ts @@ -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 }; diff --git a/src/fastify/plugins/favicon.ts b/src/fastify/plugins/favicon.ts new file mode 100644 index 0000000..e7bfe86 --- /dev/null +++ b/src/fastify/plugins/favicon.ts @@ -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>; diff --git a/src/helpers/discord.bot.ts b/src/helpers/discord.bot.ts new file mode 100644 index 0000000..eee8e96 --- /dev/null +++ b/src/helpers/discord.bot.ts @@ -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; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cd5ab24 --- /dev/null +++ b/src/index.ts @@ -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(); diff --git a/src/interfaces/environment.ts b/src/interfaces/environment.ts new file mode 100644 index 0000000..8fb82d6 --- /dev/null +++ b/src/interfaces/environment.ts @@ -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; + } + }; +} \ No newline at end of file diff --git a/src/interfaces/routes.ts b/src/interfaces/routes.ts new file mode 100644 index 0000000..540ed57 --- /dev/null +++ b/src/interfaces/routes.ts @@ -0,0 +1,8 @@ +import type { TMethod } from "../types/routes"; + +export interface IRouteInfo { + enabled: boolean; + path: string; + method: TMethod | TMethod[]; + websocket?: boolean; +} \ No newline at end of file diff --git a/src/types/routes.ts b/src/types/routes.ts new file mode 100644 index 0000000..4c8e533 --- /dev/null +++ b/src/types/routes.ts @@ -0,0 +1 @@ +export type TMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | "HEAD"; \ No newline at end of file diff --git a/src/www/public/assets/favicon.gif b/src/www/public/assets/favicon.gif new file mode 100644 index 0000000..59ac736 Binary files /dev/null and b/src/www/public/assets/favicon.gif differ diff --git a/src/www/public/assets/fonts/FantasqueSansMNerdFont-Regular.ttf b/src/www/public/assets/fonts/FantasqueSansMNerdFont-Regular.ttf new file mode 100644 index 0000000..eaca913 Binary files /dev/null and b/src/www/public/assets/fonts/FantasqueSansMNerdFont-Regular.ttf differ diff --git a/src/www/public/assets/game_icon.svg b/src/www/public/assets/game_icon.svg new file mode 100644 index 0000000..f9f068f --- /dev/null +++ b/src/www/public/assets/game_icon.svg @@ -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> diff --git a/src/www/public/css/style.css b/src/www/public/css/style.css new file mode 100644 index 0000000..84621a7 --- /dev/null +++ b/src/www/public/css/style.css @@ -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; +} diff --git a/src/www/public/css/user.style.css b/src/www/public/css/user.style.css new file mode 100644 index 0000000..21dd184 --- /dev/null +++ b/src/www/public/css/user.style.css @@ -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; +} \ No newline at end of file diff --git a/src/www/public/js/snow.js b/src/www/public/js/snow.js new file mode 100644 index 0000000..877b823 --- /dev/null +++ b/src/www/public/js/snow.js @@ -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(); +}); diff --git a/src/www/public/js/status.ws.js b/src/www/public/js/status.ws.js new file mode 100644 index 0000000..6515fd5 --- /dev/null +++ b/src/www/public/js/status.ws.js @@ -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); + } + } +}); \ No newline at end of file diff --git a/src/www/routes/index.ts b/src/www/routes/index.ts new file mode 100644 index 0000000..03c02ae --- /dev/null +++ b/src/www/routes/index.ts @@ -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 } \ No newline at end of file diff --git a/src/www/views/index.ejs b/src/www/views/index.ejs new file mode 100644 index 0000000..523336b --- /dev/null +++ b/src/www/views/index.ejs @@ -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> \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}