Compare commits

...
Sign in to create a new pull request.

64 commits
main ... main

Author SHA1 Message Date
60a52df5fa
idk how i forgot the favicon 2025-05-03 07:35:48 -04:00
a4f139406c
reformat the html for better seo? 2025-05-03 07:28:23 -04:00
167d989600
add plausible support 2025-05-03 07:07:26 -04:00
dbe894a568
add to readme 2025-05-02 13:15:30 -04:00
2330953c33
fix issue with empty badge loading 2025-05-01 20:20:55 -04:00
88b3783451
should fix transparent smallimages for activities 2025-05-01 20:17:48 -04:00
a9c1a6d8a2
fix readme 2025-04-28 18:03:10 -04:00
330c71b37f
change license to BSD 3-Clause 2025-04-28 04:11:57 -04:00
076f886e79
add streaming button, fix twitch icon 2025-04-27 18:16:48 -04:00
af872d538c
Fix lint 2025-04-27 17:58:20 -04:00
6212f47084
add spotify button based on sync id 2025-04-27 17:57:53 -04:00
1b21009ebb
move badge loading again since its not awaited 2025-04-27 17:44:35 -04:00
52e4c86808 Merge pull request 'nesting' () from KrstlSkll69/profilePage:main into main
Reviewed-on: 
2025-04-27 01:10:50 +02:00
937bfc500c formatting 2025-04-26 19:01:34 -04:00
3ae59fa54d nesting 2025-04-26 18:58:07 -04:00
94046881dd
re-add clan tags, never moved them after the js move 2025-04-26 17:28:31 -04:00
bafdfb47f9
Add more to buns purify 2025-04-26 11:48:54 -04:00
c867c57a26
Fix lint 2025-04-26 11:18:13 -04:00
b480f165ea
fix badge kv var 2025-04-26 11:17:16 -04:00
fa2de5cac9
didnt push for some reason? 2025-04-26 11:13:57 -04:00
2ee5f0512e
move to raw html, make readme use buns html rewrite and always set to
lazy image load
2025-04-26 11:10:31 -04:00
10416dbff0
add lazyload and move readme to func, add cache for readme and css 2025-04-26 10:47:12 -04:00
f6bda95f02
move load order again, add url to username to open profile 2025-04-26 10:11:29 -04:00
6078ebf0d1
fix mobile avatar and name margin top 2025-04-26 08:45:32 -04:00
0a95f61977
remove uneeded 2025-04-26 08:34:29 -04:00
1020e3ee26
fix issue with badge loading, profile indef loading if no user, remove unused files, organize the js 2025-04-26 07:29:56 -04:00
634d919239
fix readme 2025-04-26 06:38:03 -04:00
1e5b754ac9
Fix lint 2025-04-26 06:28:19 -04:00
397dc422c5
fix issue with status indactor 2025-04-26 06:25:39 -04:00
7d0c65ff8c
move discord badges before other badges 2025-04-25 23:19:36 -04:00
3b6c68c25d
add css kv var, move away from ssr ( multiple queries ), remove colors kv var, add option to disable logging per route 2025-04-25 21:20:08 -04:00
bd680ab607
move to npm logger, fix favicon, clan badges, user avatar 2025-04-23 11:58:55 -04:00
92f2280099
i never actually added the var 2025-04-22 20:21:44 -04:00
6bbf474b93
add env var, add docker files, idk how i forgot there in the readme 2025-04-22 20:21:24 -04:00
d15b69fe38
fix readme 2025-04-21 18:26:17 -04:00
91c8e341e8
forgot console log 2025-04-21 18:10:20 -04:00
a739ffb4b7
add fprgejo image bottom right 2025-04-21 16:50:07 -04:00
df6e2325d9
fix lint 2025-04-20 14:39:31 -04:00
6d46ef48d0
add redis, game icons and fix readme 2025-04-20 14:39:15 -04:00
245215265a
remove index.ts since its not needed 2025-04-20 12:45:38 -04:00
8f499daec2
fix mobile status css 2025-04-19 22:57:13 -04:00
8a16476d67
fix issue with badges not being auto shown 2025-04-19 22:50:15 -04:00
400e04cd38
add badge toggle per user kv, fix readme 2025-04-19 19:24:52 -04:00
8c696914b8
fix lint 2025-04-19 19:18:48 -04:00
83babb8c5c
add starts kv and background, fix some css 2025-04-19 19:15:11 -04:00
8b7bedbf0b
fix mobile icon 2025-04-19 14:06:37 -04:00
bf66b301ae
add badges and fix clan tags, and readme issue 2025-04-19 13:37:37 -04:00
7816210a2c
add clan badges 2025-04-18 04:31:25 -04:00
b109f67125
fix ip log issue, make changes to embed 2025-04-17 18:57:45 -04:00
23f37beef3
update to allow html readme, fx id page for colors 2025-04-17 18:26:59 -04:00
7f9f166f8a
fix readme 2025-04-11 19:50:57 -04:00
59b354e43c
add a readme and license 2025-04-11 19:31:10 -04:00
59d3a6b3e2
update package 2025-04-10 19:26:24 -04:00
0d5fbe76b7
forgot comment 2025-04-10 07:09:27 -04:00
ff0ece9626
add option to use vibrant colors from avatar needs moving around 2025-04-10 07:09:10 -04:00
30e9057ba8
update biome.json and add workflow 2025-04-10 06:29:21 -04:00
f4aeb7aafb
this is why i need a dev branch 2025-04-09 18:41:16 -04:00
5e94af5980
not me forgetting console logs 2025-04-09 18:38:24 -04:00
c54d959e7e
fix issue with rain and snow, fix ejs formatting 2025-04-09 18:35:08 -04:00
78c2eb4545
add rain and snow kv options fix issue with activity header not showing when no initial activity 2025-04-09 18:23:52 -04:00
66744ddd10
move all colors to :root, add activity small image and hover text, add support for streaming indicator, 2025-04-07 18:58:54 -04:00
d91e832eab
move to biomejs 2025-04-07 04:36:07 -04:00
7d78a74a25
move to discord proxy for images, add lanyard hb, 2025-04-06 21:41:53 -04:00
c79ee2b203
fix xss issue aka: , update depends change how activities display, remove readme title, 2025-04-06 20:59:38 -04:00
43 changed files with 2049 additions and 1185 deletions

View file

@ -2,6 +2,18 @@
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8080
REDIS_URL=redis://dragonfly:6379
REDIS_TTL=3600 # seconds
# this is only the default value if non is give in /id # this is only the default value if non is give in /id
LANYARD_USER_ID=id-here LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081
# https://www.steamgriddb.com/api/v2, if you want games to have images
STEAMGRIDDB_API_KEY=steamgrid_api_key
# https://plausible.io
PLAUSIBLE_SCRIPT_HTML=''

View file

@ -0,0 +1,24 @@
name: Code quality checks
on:
push:
pull_request:
jobs:
biome:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
- name: Install Dependencies
run: bun install
- name: Run Biome with verbose output
run: bunx biome ci . --verbose

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
bun.lock bun.lock
.env .env
logs/ logs/
.vscode/

View file

@ -1,3 +0,0 @@
{
"github-enterprise.uri": "https://git.creations.works"
}

38
Dockerfile Normal file
View file

@ -0,0 +1,38 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:latest AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# [optional] tests & build
ENV NODE_ENV=production
# copy production dependencies and source code into final image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/src ./src
COPY --from=prerelease /usr/src/app/public ./public
COPY --from=prerelease /usr/src/app/package.json .
COPY --from=prerelease /usr/src/app/tsconfig.json .
COPY --from=prerelease /usr/src/app/config ./config
COPY --from=prerelease /usr/src/app/types ./types
RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs
USER bun
ENTRYPOINT [ "bun", "run", "start" ]

28
LICENSE Normal file
View file

@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2025, creations.works
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

140
README.md
View file

@ -1,3 +1,139 @@
# Cool little discord profile page # Discord Profile Page
E A cool little web app that shows your Discord profile, current activity, and more. Built with Bun.
# Preview
https://creations.works
---
## Requirements
This project depends on the following services to function properly:
### 1. Lanyard Backend
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance to fetch real-time Discord presence data.
Make sure the Lanyard instance is running and accessible before using this.
### 2. Redis Instance
A Redis-compatible key-value store is required to cache third-party data (e.g., SteamGridDB icons).
I recommend [Dragonfly](https://www.dragonflydb.io/), a high-performance drop-in replacement for Redis.
### 3. Badge API
A lightweight API to render Discord-style badges.
>Only needed if you want to show badges on profiles:
https://git.creations.works/creations/badgeAPI
### 4. SteamGridDB
>Only needed if you want to fetch game icons that Discord doesnt provide:
https://www.steamgriddb.com/api/v2
---
## Getting Started
### 1. Clone & Install
```bash
git clone https://git.creations.works/creations/profilePage.git
cd profilePage
bun install
```
### 2. Configure Environment
Copy the example environment file and update it:
```bash
cp .env.example .env
```
#### `.env` Variables
| Variable | Description |
|-----------------------|-----------------------------------------------------------------------------|
| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) |
| `PORT` | Port to run the server on (default: `8080`) |
| `REDIS_URL` | Redis connection string |
| `LANYARD_USER_ID` | Your Discord user ID, for the default page |
| `LANYARD_INSTANCE` | Endpoint of the Lanyard instance |
| `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) |
| `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons |
#### Optional Lanyard KV Variables (per-user customization)
These can be defined in Lanyard's KV store to customize the page:
| Variable | Description |
|-----------|--------------------------------------------------------------------|
| `snow` | Enables snow background (`true` / `false`) |
| `rain` | Enables rain background (`true` / `false`) |
| `stars` | Enables starfield background (`true` / `false`) |
| `badges` | Enables badge fetching (`true` / `false`) |
| `readme` | URL to a README displayed on the profile (`.md` or `.html`) |
| `css` | URL to a css to change styles on the page, no import or require allowed |
---
### 3. Start the Instance
```bash
bun run start
```
---
## Optional: Analytics with Plausible
You can enable [Plausible Analytics](https://plausible.io) tracking by setting a script snippet in your environment.
### `.env` Variable
| Variable | Description |
|-------------------------|------------------------------------------------------------------------|
| `PLAUSIBLE_SCRIPT_HTML` | Full `<script>` tag(s) to inject into the `<head>` for analytics |
#### Example
```env
PLAUSIBLE_SCRIPT_HTML='<script defer data-domain="example.com" src="https://plausible.example.com/js/script.js"></script><script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>'
```
- The script will only be injected if this variable is set.
- Plausible provides the correct script when you add a domain.
- Be sure to wrap it in single quotes (`'`) so it works in `.env`.
---
## Docker Support
### Build & Start with Docker Compose
```bash
docker compose up -d --build
```
Make sure the `.env` file is configured correctly before starting the container.
---
## Routes
These are the main public routes exposed by the server:
| Route | Description |
|---------|-----------------------------------------------------------------------------|
| `/` | Loads the profile page for the default Discord user defined in `.env` (`LANYARD_USER_ID`) |
| `/[id]` | Loads the profile page for a specific Discord user ID passed in the URL |
> Example: `https://creations.works/209830981060788225` shows the profile of that specific user.
---
## License
[BSD 3](LICENSE)

44
biome.json Normal file
View file

@ -0,0 +1,44 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": true,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"css": {
"formatter": {
"indentStyle": "tab",
"lineEnding": "lf"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"indentStyle": "tab",
"lineEnding": "lf",
"jsxQuoteStyle": "double",
"semicolons": "always"
}
}
}

29
compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
profile-page:
container_name: profilePage
build:
context: .
restart: unless-stopped
ports:
- "${PORT:-6600}:${PORT:-6600}"
env_file:
- .env
networks:
- profilePage-network
dragonfly:
image: 'docker.dragonflydb.io/dragonflydb/dragonfly'
restart: unless-stopped
ulimits:
memlock: -1
volumes:
- dragonflydata:/data
networks:
- profilePage-network
volumes:
dragonflydata:
networks:
profilePage-network:
driver: bridge

View file

@ -1,12 +1,22 @@
export const environment: Environment = { export const environment: Environment = {
port: parseInt(process.env.PORT || "8080", 10), port: Number.parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0", host: process.env.HOST || "0.0.0.0",
development: development:
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
process.argv.includes("--dev"),
}; };
export const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
export const lanyardConfig: LanyardConfig = { export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "", userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
}; };
export const badgeApi: string | null = process.env.BADGE_API_URL || null;
export const steamGridDbKey: string | undefined =
process.env.STEAMGRIDDB_API_KEY;
export const plausibleScript: string | null =
process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null;

View file

@ -1,132 +0,0 @@
import pluginJs from "@eslint/js";
import tseslintPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import prettier from "eslint-plugin-prettier";
import promisePlugin from "eslint-plugin-promise";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import unicorn from "eslint-plugin-unicorn";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
{
files: ["**/*.{js,mjs,cjs}"],
languageOptions: {
globals: globals.node,
},
...pluginJs.configs.recommended,
plugins: {
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
promise: promisePlugin,
prettier: prettier,
unicorn: unicorn,
},
rules: {
"eol-last": ["error", "always"],
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"prettier/prettier": [
"error",
{
useTabs: true,
tabWidth: 4,
},
],
indent: ["error", "tab", { SwitchCase: 1 }],
"unicorn/filename-case": [
"error",
{
case: "camelCase",
},
],
},
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
globals: globals.node,
},
plugins: {
"@typescript-eslint": tseslintPlugin,
"simple-import-sort": simpleImportSort,
"unused-imports": unusedImports,
promise: promisePlugin,
prettier: prettier,
unicorn: unicorn,
},
rules: {
...tseslintPlugin.configs.recommended.rules,
quotes: ["error", "double"],
"eol-last": ["error", "always"],
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"prettier/prettier": [
"error",
{
useTabs: true,
tabWidth: 4,
},
],
indent: ["error", "tab", { SwitchCase: 1 }],
"unicorn/filename-case": [
"error",
{
case: "camelCase",
},
],
"@typescript-eslint/explicit-function-return-type": ["error"],
"@typescript-eslint/explicit-module-boundary-types": ["error"],
"@typescript-eslint/typedef": [
"error",
{
arrowParameter: true,
variableDeclaration: true,
propertyDeclaration: true,
memberVariableDeclaration: true,
parameter: true,
},
],
},
},
];

View file

@ -5,31 +5,20 @@
"scripts": { "scripts": {
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"dev": "bun run --hot src/index.ts --dev", "dev": "bun run --hot src/index.ts --dev",
"lint": "eslint", "lint": "bunx biome ci . --verbose",
"lint:fix": "bun lint --fix", "lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lockdb" "cleanup": "rm -rf logs node_modules bun.lockdb"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.24.0", "@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.8", "@types/bun": "^1.2.8",
"@types/ejs": "^3.1.5", "globals": "^16.0.0"
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"eslint": "^9.24.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.15.0",
"prettier": "^3.5.3"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"ejs": "^3.1.10", "@creations.works/logger": "^1.0.3",
"node-vibrant": "^4.0.3",
"marked": "^15.0.7" "marked": "^15.0.7"
} }
} }

Binary file not shown.

Before

Width: 48px  |  Height: 48px  |  Size: 15 KiB

After

Width: 256px  |  Height: 256px  |  Size: 168 KiB

BIN
public/assets/favicon.png Normal file

Binary file not shown.

After

(image error) Size: 9 KiB

View file

@ -0,0 +1,36 @@
<svg viewBox="0 0 212 212" xmlns="http://www.w3.org/2000/svg">
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<rdf:RDF>
<cc:Work rdf:about="https://codeberg.org/forgejo/meta/src/branch/readme/branding#logo">
<dc:title>Forgejo logo</dc:title>
<cc:creator rdf:resource="https://caesarschinas.com/"><cc:attributionName>Caesar Schinas</cc:attributionName></cc:creator>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
</cc:Work>
</rdf:RDF>
</metadata>
<style type="text/css">
circle {
fill: none;
stroke: #000;
stroke-width: 15;
}
path {
fill: none;
stroke: #000;
stroke-width: 25;
}
.orange {
stroke:#ff6600;
}
.red {
stroke:#d40000;
}
</style>
<g transform="translate(6,6)">
<path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange"/>
<path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red"/>
<circle cx="142" cy="20" r="18" class="orange"/>
<circle cx="142" cy="88" r="18" class="red"/>
<circle cx="58" cy="180" r="18" class="red"/>
</g>
</svg>

After

(image error) Size: 1.2 KiB

View file

@ -12,7 +12,7 @@ body {
padding: 2rem; padding: 2rem;
background: #1a1a1d; background: #1a1a1d;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 0 20px rgba(0,0,0,0.3); box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
} }
.error-title { .error-title {
font-size: 2rem; font-size: 2rem;

View file

@ -1,7 +1,83 @@
.raindrop {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.star,
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.star {
animation: twinkle ease-in-out infinite alternate;
}
.shooting-star {
position: absolute;
background: linear-gradient(90deg, white, transparent);
width: 100px;
height: 2px;
opacity: 0.8;
border-radius: 2px;
transform-origin: left center;
}
@keyframes twinkle {
from {
opacity: 0.3;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.2);
}
}
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
transition: opacity 0.5s ease;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid var(--border-color);
border-top: 5px solid var(--progress-fill);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* actual styles below */
body { body {
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
background-color: #0e0e10; background-color: var(--background);
color: #ffffff; color: var(--text-color);
margin: 0; margin: 0;
padding: 2rem; padding: 2rem;
display: flex; display: flex;
@ -9,19 +85,59 @@ body {
align-items: center; align-items: center;
} }
main {
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.open-source-logo {
width: 2rem;
height: 2rem;
margin: 0;
padding: 0;
cursor: pointer;
position: fixed;
bottom: 1rem;
right: 0.5rem;
z-index: 1000;
opacity: 0.5;
transition: opacity 0.3s ease;
&:hover {
opacity: 1 !important;
}
}
.hidden {
display: none !important;
}
.activity-header.hidden {
display: none;
}
.user-card { .user-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 2rem; margin-bottom: 2rem;
max-width: 600px; max-width: 700px;
width: 100%; width: 100%;
} }
.avatar-status-wrapper { .avatar-status-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 2rem;
width: fit-content;
max-width: 700px;
} }
.avatar-wrapper { .avatar-wrapper {
@ -36,6 +152,29 @@ body {
border-radius: 50%; border-radius: 50%;
} }
.badges {
max-width: 700px;
box-sizing: border-box;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.badge {
width: 26px;
height: 26px;
border-radius: 50%;
}
.decoration { .decoration {
position: absolute; position: absolute;
top: -18px; top: -18px;
@ -52,35 +191,58 @@ body {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
border: 4px solid #0e0e10; border: 4px solid var(--background);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.status-indicator.online { .status-indicator.online {
background-color: #23a55a; background-color: var(--status-online);
} }
.status-indicator.idle { .status-indicator.idle {
background-color: #f0b232; background-color: var(--status-idle);
} }
.status-indicator.dnd { .status-indicator.dnd {
background-color: #f23f43; background-color: var(--status-dnd);
} }
.status-indicator.offline { .status-indicator.offline {
background-color: #747f8d; background-color: var(--status-offline);
}
.status-indicator.streaming {
background-color: var(--status-streaming);
} }
.platform-icon.mobile-only { .platform-icon.mobile-only {
position: absolute; position: absolute;
bottom: 4px; bottom: 0;
right: 4px; right: 4px;
width: 30px; width: 30px;
height: 30px; height: 30px;
pointer-events: none; pointer-events: none;
background-color: var(--background);
padding: 0.3rem 0.1rem;
border-radius: 8px;
}
.platform-icon.mobile-only.dnd {
fill: var(--status-dnd);
}
.platform-icon.mobile-only.idle {
fill: var(--status-idle);
}
.platform-icon.mobile-only.online {
fill: var(--status-online);
}
.platform-icon.mobile-only.offline {
fill: var(--status-offline);
}
.platform-icon.mobile-only.streaming {
fill: var(--status-streaming);
} }
.user-info { .user-info {
@ -88,15 +250,65 @@ body {
flex-direction: column; flex-direction: column;
} }
.user-info-inner {
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
gap: 0.5rem;
}
.user-info-inner a {
text-decoration: none;
color: var(--link-color);
}
.user-info-inner h1 {
font-size: 2rem;
margin: 0;
}
.clan-badge {
width: fit-content;
height: fit-content;
border-radius: 8px;
background-color: var(--card-bg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.3rem 0.5rem;
text-align: center;
align-items: center;
justify-content: center;
}
.clan-badge img {
width: 20px;
height: 20px;
margin: 0;
padding: 0;
}
.clan-badge span {
font-size: 0.9rem;
color: var(--text-color);
margin: 0;
font-weight: 600;
}
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
margin: 0; margin: 0;
color: #00b0f4; color: var(--link-color);
} }
.custom-status { .custom-status {
font-size: 1.2rem; font-size: 1.2rem;
color: #bbb; color: var(--text-subtle);
margin-top: 0.25rem; margin-top: 0.25rem;
word-break: break-word; word-break: break-word;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@ -107,7 +319,6 @@ h1 {
flex-wrap: wrap; flex-wrap: wrap;
} }
.custom-status .custom-emoji { .custom-status .custom-emoji {
width: 20px; width: 20px;
height: 20px; height: 20px;
@ -125,7 +336,18 @@ ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
width: 100%; width: 100%;
max-width: 600px; max-width: 700px;
}
.activities-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 700px;
box-sizing: border-box;
padding: 0;
margin: 0;
} }
.activities { .activities {
@ -133,7 +355,8 @@ ul {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
width: 100%; width: 100%;
max-width: 600px; max-width: 700px;
box-sizing: border-box;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
@ -142,19 +365,57 @@ ul {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
background: #1a1a1d; background-color: var(--card-bg);
padding: 1rem; padding: 0.75rem 1rem;
border-radius: 6px; border-radius: 10px;
box-shadow: 0 0 0 1px #2e2e30; border: 1px solid var(--border-color);
transition: background 0.2s ease;
align-items: flex-start; transition: background-color 0.3s ease;
&:hover {
background: var(--card-hover-bg);
}
} }
.activity:hover { .activity-wrapper {
background: #2a2a2d; display: flex;
flex-direction: column;
width: 100%;
} }
.activity-art { .activity-wrapper-inner {
display: flex;
flex-direction: row;
gap: 1rem;
}
.activity-image-wrapper {
position: relative;
width: 80px;
height: 80px;
}
.no-asset {
display: none !important;
}
.activity-image-small {
width: 25px;
height: 25px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border-color: var(--card-bg);
background-color: var(--card-bg);
border-width: 2px;
border-style: solid;
position: absolute;
bottom: -6px;
right: -10px;
}
.activity-image {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 6px; border-radius: 6px;
@ -165,7 +426,15 @@ ul {
.activity-content { .activity-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
flex: 1; flex: 1;
gap: 0.5rem;
position: relative;
}
.activity-top {
display: flex;
flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
@ -175,64 +444,91 @@ ul {
align-items: flex-start; align-items: flex-start;
} }
.activity-bottom {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.activity-name { .activity-name {
font-weight: bold; font-weight: 600;
font-size: 1.1rem; font-size: 1rem;
color: #ffffff; color: var(--text-color);
} }
.activity-detail { .activity-detail {
font-size: 0.95rem; font-size: 0.875rem;
color: #ccc; color: var(--text-secondary);
} }
.activity-timestamp { .activity-timestamp {
font-size: 0.8rem; font-size: 0.75rem;
color: #777; color: var(--text-secondary);
text-align: right; text-align: right;
margin-left: auto;
white-space: nowrap;
} }
.progress-bar { .progress-bar {
height: 6px; height: 4px;
background-color: #333; background-color: var(--border-color);
border-radius: 3px; border-radius: 2px;
margin-top: 1rem;
overflow: hidden; overflow: hidden;
width: 100%;
margin-top: 0.5rem;
} }
.progress-fill { .progress-fill {
background-color: var(--progress-fill);
transition: width 0.4s ease;
height: 100%; height: 100%;
background-color: #00b0f4; }
transition: width 0.5s ease;
.progress-bar,
.progress-time-labels {
width: 100%;
} }
.progress-time-labels { .progress-time-labels {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.75rem; font-size: 0.75rem;
color: #888; color: var(--text-muted);
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.activity-type-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.activity-type-label {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
color: var(--blockquote-color);
margin-bottom: 0.5rem;
display: block;
}
.activity-header.no-timestamp { .activity-header.no-timestamp {
justify-content: flex-start; justify-content: flex-start;
} }
.progress-time-labels.paused .progress-current::after { .progress-time-labels.paused .progress-current::after {
content: " ⏸"; content: " ⏸";
color: #f0b232; color: var(--status-idle);
} }
.activity-buttons { .activity-buttons {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.75rem; margin-top: 0.75rem;
justify-content: flex-end;
} }
.activity-button { .activity-button {
background-color: #5865f2; background-color: var(--progress-fill);
color: white; color: white;
border: none; border: none;
border-radius: 3px; border-radius: 3px;
@ -242,18 +538,17 @@ ul {
text-decoration: none; text-decoration: none;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
display: inline-block; display: inline-block;
}
.activity-button:hover { &:hover {
background-color: #4752c4; background-color: var(--button-hover-bg);
text-decoration: none; text-decoration: none;
} }
.activity-button.disabled { &:disabled {
background-color: #4e5058; background-color: var(--button-disabled-bg);
cursor: default; cursor: not-allowed;
pointer-events: none; opacity: 0.8;
opacity: 0.8; }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@ -270,6 +565,16 @@ ul {
.user-card { .user-card {
width: 100%; width: 100%;
align-items: center; align-items: center;
margin-top: 2rem;
}
.badges {
max-width: 100%;
border-radius: 0;
border: none;
background-color: transparent;
margin-top: 0;
box-shadow: none;
} }
.avatar-status-wrapper { .avatar-status-wrapper {
@ -280,6 +585,13 @@ ul {
width: 100%; width: 100%;
} }
.activity-image-wrapper {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
.avatar-wrapper { .avatar-wrapper {
width: 96px; width: 96px;
height: 96px; height: 96px;
@ -334,21 +646,31 @@ ul {
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
border-radius:0; border-radius: 0;
} }
.activity-art { .activity-image {
width: 100%; width: 100%;
max-width: 300px; max-width: 100%;
height: auto; height: auto;
border-radius: 8px; border-radius: 8px;
} }
.activity-image-small {
width: 40px;
height: 40px;
}
.activity-content { .activity-content {
width: 100%; width: 100%;
align-items: center; align-items: center;
} }
.activity-wrapper-inner {
flex-direction: column;
align-items: center;
}
.activity-header { .activity-header {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -385,12 +707,16 @@ ul {
/* readme :p */ /* readme :p */
.readme { .readme {
max-width: 600px; max-width: fit-content;
min-width: 700px;
overflow: hidden;
width: 100%; width: 100%;
background: #1a1a1d; background: var(--readme-bg);
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 0 1px #2e2e30; border: 1px solid var(--border-color);
margin-top: 1rem;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
@ -399,13 +725,13 @@ ul {
.readme h2 { .readme h2 {
margin-top: 0; margin-top: 0;
color: #00b0f4; color: var(--link-color);
} }
.markdown-body { .markdown-body {
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
color: #ddd; color: var(--text-color);
} }
.markdown-body h1, .markdown-body h1,
@ -414,7 +740,7 @@ ul {
.markdown-body h4, .markdown-body h4,
.markdown-body h5, .markdown-body h5,
.markdown-body h6 { .markdown-body h6 {
color: #ffffff; color: var(--text-color);
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -424,7 +750,7 @@ ul {
} }
.markdown-body a { .markdown-body a {
color: #00b0f4; color: var(--link-color);
text-decoration: none; text-decoration: none;
} }
@ -433,7 +759,7 @@ ul {
} }
.markdown-body code { .markdown-body code {
background: #2e2e30; background: var(--border-color);
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
@ -441,7 +767,7 @@ ul {
} }
.markdown-body pre { .markdown-body pre {
background: #2e2e30; background: var(--border-color);
padding: 1rem; padding: 1rem;
border-radius: 6px; border-radius: 6px;
overflow-x: auto; overflow-x: auto;
@ -456,9 +782,9 @@ ul {
} }
.markdown-body blockquote { .markdown-body blockquote {
border-left: 4px solid #00b0f4; border-left: 4px solid var(--link-color);
padding-left: 1rem; padding-left: 1rem;
color: #aaa; color: var(--blockquote-color);
margin: 1rem 0; margin: 1rem 0;
} }
@ -468,7 +794,8 @@ ul {
@media (max-width: 600px) { @media (max-width: 600px) {
.readme { .readme {
width: 100%; max-width: 100%;
min-width: 100%;
padding: 1rem; padding: 1rem;
margin-top: 1rem; margin-top: 1rem;

29
public/css/root.css Normal file
View file

@ -0,0 +1,29 @@
:root {
--background: #0e0e10;
--readme-bg: #1a1a1d;
--card-bg: #1e1f22;
--card-hover-bg: #2a2a2d;
--border-color: #2e2e30;
--text-color: #ffffff;
--text-subtle: #bbb;
--text-secondary: #b5bac1;
--text-muted: #888;
--link-color: #00b0f4;
--button-bg: #5865f2;
--button-hover-bg: #4752c4;
--button-disabled-bg: #2d2e31;
--progress-bg: #f23f43;
--progress-fill: #5865f2;
--status-online: #23a55a;
--status-idle: #f0b232;
--status-dnd: #e03e3e;
--status-offline: #747f8d;
--status-streaming: #b700ff;
--blockquote-color: #aaa;
--code-bg: #2e2e30;
}

View file

@ -1,36 +1,49 @@
/* eslint-disable indent */ const head = document.querySelector("head");
const userId = head?.dataset.userId;
const activityProgressMap = new Map(); const activityProgressMap = new Map();
let instanceUri = head?.dataset.instanceUri;
let badgeURL = head?.dataset.badgeUrl;
let socket;
let badgesLoaded = false;
function formatTime(ms) { function formatTime(ms) {
const totalSecs = Math.floor(ms / 1000); const totalSecs = Math.floor(ms / 1000);
const mins = Math.floor(totalSecs / 60); const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60; const secs = totalSecs % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
return `${String(hours).padStart(1, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function formatVerbose(ms) {
const totalSecs = Math.floor(ms / 1000);
const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
return `${hours}h ${mins}m ${secs}s`;
} }
function updateElapsedAndProgress() { function updateElapsedAndProgress() {
const now = Date.now(); const now = Date.now();
document.querySelectorAll(".activity-timestamp").forEach((el) => { for (const el of document.querySelectorAll(".activity-timestamp")) {
const start = Number(el.dataset.start); const start = Number(el.dataset.start);
if (!start) return; if (!start) continue;
const elapsed = now - start; const elapsed = now - start;
const mins = Math.floor(elapsed / 60000);
const secs = Math.floor((elapsed % 60000) / 1000);
const display = el.querySelector(".elapsed"); const display = el.querySelector(".elapsed");
if (display) if (display) display.textContent = `(${formatVerbose(elapsed)} ago)`;
display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`; }
});
document.querySelectorAll(".progress-bar").forEach((bar) => { for (const bar of document.querySelectorAll(".progress-bar")) {
const start = Number(bar.dataset.start); const start = Number(bar.dataset.start);
const end = Number(bar.dataset.end); const end = Number(bar.dataset.end);
if (!start || !end || end <= start) return; if (!start || !end || end <= start) continue;
const duration = end - start; const duration = end - start;
const elapsed = now - start; const elapsed = Math.min(now - start, duration);
const progress = Math.min( const progress = Math.min(
100, 100,
Math.max(0, Math.floor((elapsed / duration) * 100)), Math.max(0, Math.floor((elapsed / duration) * 100)),
@ -38,14 +51,15 @@ function updateElapsedAndProgress() {
const fill = bar.querySelector(".progress-fill"); const fill = bar.querySelector(".progress-fill");
if (fill) fill.style.width = `${progress}%`; if (fill) fill.style.width = `${progress}%`;
}); }
document.querySelectorAll(".progress-time-labels").forEach((label) => { for (const label of document.querySelectorAll(".progress-time-labels")) {
const start = Number(label.dataset.start); const start = Number(label.dataset.start);
const end = Number(label.dataset.end); const end = Number(label.dataset.end);
if (!start || !end || end <= start) return; if (!start || !end || end <= start) continue;
const current = Math.max(0, now - start); const isPaused = now > end;
const current = isPaused ? end - start : Math.max(0, now - start);
const total = end - start; const total = end - start;
const currentEl = label.querySelector(".progress-current"); const currentEl = label.querySelector(".progress-current");
@ -54,7 +68,7 @@ function updateElapsedAndProgress() {
const id = `${start}-${end}`; const id = `${start}-${end}`;
const last = activityProgressMap.get(id); const last = activityProgressMap.get(id);
if (last !== undefined && last === current) { if (isPaused || (last !== undefined && last === current)) {
label.classList.add("paused"); label.classList.add("paused");
} else { } else {
label.classList.remove("paused"); label.classList.remove("paused");
@ -62,49 +76,47 @@ function updateElapsedAndProgress() {
activityProgressMap.set(id, current); activityProgressMap.set(id, current);
if (currentEl) currentEl.textContent = formatTime(current); if (currentEl) {
currentEl.textContent = isPaused
? `Paused at ${formatTime(current)}`
: formatTime(current);
}
if (totalEl) totalEl.textContent = formatTime(total); if (totalEl) totalEl.textContent = formatTime(total);
}); }
} }
updateElapsedAndProgress(); function loadEffectScript(effect) {
setInterval(updateElapsedAndProgress, 1000); const existing = document.querySelector(`script[data-effect="${effect}"]`);
if (existing) return;
const head = document.querySelector("head"); const script = document.createElement("script");
let userId = head?.dataset.userId; script.src = `/public/js/${effect}.js`;
let instanceUri = head?.dataset.instanceUri; script.dataset.effect = effect;
document.head.appendChild(script);
}
if (userId && instanceUri) { function resolveActivityImage(img, applicationId) {
if (!instanceUri.startsWith("http")) { if (!img) return null;
instanceUri = `https://${instanceUri}`;
if (img.startsWith("mp:external/")) {
return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`;
} }
const wsUri = instanceUri if (img.includes("/https/")) {
.replace(/^http:/, "ws:") const clean = img.split("/https/")[1];
.replace(/^https:/, "wss:") return clean ? `https://${clean}` : null;
.replace(/\/$/, ""); }
const socket = new WebSocket(`${wsUri}/socket`); if (img.startsWith("spotify:")) {
return `https://i.scdn.co/image/${img.split(":")[1]}`;
}
socket.addEventListener("open", () => { if (img.startsWith("twitch:")) {
socket.send( const username = img.split(":")[1];
JSON.stringify({ return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`;
op: 2, }
d: {
subscribe_to_id: userId,
},
}),
);
});
socket.addEventListener("message", (event) => { return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`;
const payload = JSON.parse(event.data);
if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") {
updatePresence(payload.d);
updateElapsedAndProgress();
}
});
} }
function buildActivityHTML(activity) { function buildActivityHTML(activity) {
@ -118,90 +130,299 @@ function buildActivityHTML(activity) {
? Math.min(100, Math.floor((elapsed / total) * 100)) ? Math.min(100, Math.floor((elapsed / total) * 100))
: null; : null;
const img = activity.assets?.large_image;
let art = null; let art = null;
if (img?.includes("https")) { let smallArt = null;
const clean = img.split("/https/")[1];
if (clean) art = `https://${clean}`; if (activity.assets) {
} else if (img?.startsWith("spotify:")) { art = resolveActivityImage(
art = `https://i.scdn.co/image/${img.split(":")[1]}`; activity.assets.large_image,
} else if (img) { activity.application_id,
art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; );
smallArt = resolveActivityImage(
activity.assets.small_image,
activity.application_id,
);
} }
const activityTypeMap = {
0: "Playing",
1: "Streaming",
2: "Listening",
3: "Watching",
4: "Custom Status",
5: "Competing",
};
const activityType =
activity.name === "Spotify"
? "Listening to Spotify"
: activity.name === "TIDAL"
? "Listening to TIDAL"
: activityTypeMap[activity.type] || "Playing";
const activityTimestamp = const activityTimestamp =
!total && start start && progress === null
? ` ? `<div class="activity-timestamp" data-start="${start}">
<div class="activity-timestamp" data-start="${start}"> <span>Since: ${new Date(start).toLocaleTimeString("en-GB", {
<span> hour: "2-digit",
Since: ${new Date(start).toLocaleTimeString("en-GB", { minute: "2-digit",
hour: "2-digit", second: "2-digit",
minute: "2-digit", })} <span class="elapsed"></span></span>
second: "2-digit", </div>`
})} <span class="elapsed"></span>
</span>
</div>`
: ""; : "";
const buttons = (activity.buttons || [])
.map((button, index) => {
const label = typeof button === "string" ? button : button.label;
let url = null;
if (typeof button === "object" && button.url) {
url = button.url;
} else if (index === 0 && activity.url) {
url = activity.url;
}
return url
? `<a href="${url}" class="activity-button" target="_blank" rel="noopener noreferrer">${label}</a>`
: null;
})
.filter(Boolean);
if (!buttons.length && activity.name === "Twitch" && activity.url) {
buttons.push(
`<a href="${activity.url}" class="activity-button" target="_blank" rel="noopener noreferrer">Watch on Twitch</a>`,
);
}
if (activity.name === "Spotify" && activity.sync_id) {
buttons.push(
`<a href="https://open.spotify.com/track/${activity.sync_id}" class="activity-button" target="_blank" rel="noopener noreferrer">Listen on Spotify</a>`,
);
}
const activityButtons = buttons.length
? `<div class="activity-buttons">${buttons.join("")}</div>`
: "";
const progressBar = const progressBar =
progress !== null progress !== null
? ` ? `<div class="progress-bar" data-start="${start}" data-end="${end}">
<div class="progress-bar" data-start="${start}" data-end="${end}">
<div class="progress-fill" style="width: ${progress}%"></div> <div class="progress-fill" style="width: ${progress}%"></div>
</div> </div>
<div class="progress-time-labels" data-start="${start}" data-end="${end}"> <div class="progress-time-labels" data-start="${start}" data-end="${end}">
<span class="progress-current">${formatTime(elapsed)}</span> <span class="progress-current">${formatTime(elapsed)}</span>
<span class="progress-total">${formatTime(total)}</span> <span class="progress-total">${formatTime(total)}</span>
</div> </div>`
`
: ""; : "";
const activityButtons = activity.buttons && activity.buttons.length > 0 const isMusic = activity.type === 2 || activity.type === 3;
? `<div class="activity-buttons">
${activity.buttons.map((button, index) => { const primaryLine = isMusic ? activity.details : activity.name;
const buttonLabel = typeof button === 'string' ? button : button.label; const secondaryLine = isMusic ? activity.state : activity.details;
let buttonUrl = null; const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
if (typeof button === 'object' && button.url) {
buttonUrl = button.url; const activityArt = `<div class="activity-image-wrapper ${art ?? "no-asset"}">
} <img
else if (index === 0 && activity.url) { class="activity-image${!art ? " no-asset" : ""}"
buttonUrl = activity.url; src="${art ?? ""}"
} data-name="${activity.name}"
if (buttonUrl) { ${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}
return `<a href="${buttonUrl}" class="activity-button" target="_blank" rel="noopener noreferrer">${buttonLabel}</a>`; />
} else { ${`<img class="activity-image-small ${smallArt ?? "no-asset"}" src="${smallArt ?? ""}" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>`}
return `<span class="activity-button disabled">${buttonLabel}</span>`; </div>`;
}
}).join('')}
</div>`
: '';
return ` return `
<li class="activity"> <li class="activity">
${art ? `<img class="activity-art" src="${art}" alt="Art">` : ""} <div class="activity-wrapper">
<div class="activity-content"> <div class="activity-type-wrapper">
<div class="activity-header ${progress !== null ? "no-timestamp" : ""}"> <span class="activity-type-label" data-type="${activity.type}">${activityType}</span>
<span class="activity-name">${activity.name}</span>
${activityTimestamp} ${activityTimestamp}
</div> </div>
${activity.details ? `<div class="activity-detail">${activity.details}</div>` : ""} <div class="activity-wrapper-inner">
${activity.state ? `<div class="activity-detail">${activity.state}</div>` : ""} ${activityArt}
${activityButtons} <div class="activity-content">
<div class="inner-content">
<div class="activity-top">
<div class="activity-header ${progress !== null ? "no-timestamp" : ""}">
<span class="activity-name">${primaryLine}</span>
</div>
${secondaryLine ? `<div class="activity-detail">${secondaryLine}</div>` : ""}
${tertiaryLine ? `<div class="activity-detail">${tertiaryLine}</div>` : ""}
</div>
<div class="activity-bottom">
${activityButtons}
</div>
</div>
</div>
</div>
${progressBar} ${progressBar}
</div> </div>
</li> </li>
`; `;
} }
function updatePresence(data) { async function loadBadges(userId, options = {}) {
const avatarWrapper = document.querySelector(".avatar-wrapper"); const {
const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); services = [],
const mobileIcon = avatarWrapper?.querySelector( seperated = false,
".platform-icon.mobile-only", cache = true,
); targetId = "badges",
serviceOrder = [],
} = options;
const userInfo = document.querySelector(".user-info"); const params = new URLSearchParams();
const customStatus = userInfo?.querySelector(".custom-status"); if (services.length) params.set("services", services.join(","));
if (seperated) params.set("seperated", "true");
if (!cache) params.set("cache", "false");
const url = `${badgeURL}${userId}?${params.toString()}`;
const target = document.getElementById(targetId);
if (!target) return;
target.classList.add("hidden");
try {
const res = await fetch(url);
const json = await res.json();
if (
!res.ok ||
!json.badges ||
Object.values(json.badges).every(
(arr) => !Array.isArray(arr) || arr.length === 0,
)
) {
target.textContent = "Failed to load badges.";
return;
}
target.innerHTML = "";
const badgesByService = json.badges;
const renderedServices = new Set();
const renderBadges = (badges) => {
for (const badge of badges) {
const img = document.createElement("img");
img.src = badge.badge;
img.alt = badge.tooltip;
img.title = badge.tooltip;
img.className = "badge";
target.appendChild(img);
}
};
for (const serviceName of serviceOrder) {
const badges = badgesByService[serviceName];
if (Array.isArray(badges) && badges.length) {
renderBadges(badges);
renderedServices.add(serviceName);
}
}
for (const [serviceName, badges] of Object.entries(badgesByService)) {
if (renderedServices.has(serviceName)) continue;
if (Array.isArray(badges) && badges.length) {
renderBadges(badges);
}
}
target.classList.remove("hidden");
} catch (err) {
console.error(err);
target.innerHTML = "";
target.classList.add("hidden");
}
}
async function populateReadme(data) {
const readmeSection = document.querySelector(".readme");
if (readmeSection && data.kv?.readme) {
const url = data.kv.readme;
try {
const res = await fetch(`/api/readme?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error("Failed to fetch readme");
const text = await res.text();
readmeSection.innerHTML = `<div class="markdown-body">${text}</div>`;
readmeSection.classList.remove("hidden");
} catch (err) {
console.error("Failed to load README", err);
readmeSection.classList.add("hidden");
}
} else if (readmeSection) {
readmeSection.classList.add("hidden");
}
}
async function updatePresence(data) {
const cssLink = data.kv?.css;
if (cssLink) {
try {
const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`);
if (!res.ok) throw new Error("Failed to fetch CSS");
const cssText = await res.text();
const style = document.createElement("style");
style.textContent = cssText;
document.head.appendChild(style);
} catch (err) {
console.error("Failed to load CSS", err);
}
}
if (!badgesLoaded && data && data.kv.badges !== "false") {
loadBadges(userId, {
services: [],
seperated: true,
cache: true,
targetId: "badges",
serviceOrder: ["discord", "equicord", "reviewdb", "vencord"],
});
badgesLoaded = true;
}
const avatarWrapper = document.querySelector(".avatar-wrapper");
const avatarImg = avatarWrapper?.querySelector(".avatar");
const usernameEl = document.querySelector(".username");
if (!data.discord_user) {
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.innerHTML = `
<div class="error-message">
<p>Failed to load user data.</p>
</div>
`;
loadingOverlay.style.opacity = "1";
avatarWrapper.classList.add("hidden");
avatarImg.classList.add("hidden");
usernameEl.classList.add("hidden");
document.title = "Error";
}
return;
}
if (avatarImg && data.discord_user?.avatar) {
const newAvatarUrl = `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}`;
avatarImg.src = newAvatarUrl;
avatarImg.classList.remove("hidden");
const siteIcon = document.getElementById("site-icon");
if (siteIcon) {
siteIcon.href = newAvatarUrl;
}
}
if (usernameEl) {
const username =
data.discord_user.global_name || data.discord_user.username;
usernameEl.innerHTML = `<a href="https://discord.com/users/${data.discord_user.id}" target="_blank" rel="noopener noreferrer">${username}</a>`;
document.title = username;
}
updateClanBadge(data);
const platform = { const platform = {
mobile: data.active_on_discord_mobile, mobile: data.active_on_discord_mobile,
@ -209,42 +430,244 @@ function updatePresence(data) {
desktop: data.active_on_discord_desktop, desktop: data.active_on_discord_desktop,
}; };
if (statusIndicator) { let status = "offline";
statusIndicator.className = `status-indicator ${data.discord_status}`; if (data.activities.some((activity) => activity.type === 1)) {
status = "streaming";
} else {
status = data.discord_status;
} }
if (platform.mobile && !mobileIcon) { for (const el of avatarWrapper.querySelectorAll(".platform-icon")) {
avatarWrapper.innerHTML += ` const platformType = ["mobile-only", "desktop-only", "web-only"].find(
<svg class="platform-icon mobile-only" viewBox="0 0 1000 1500" fill="#43a25a" aria-label="Mobile" width="17" height="17"> (type) => el.classList.contains(type),
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/> );
</svg>
if (!platformType) continue;
const active =
(platformType === "mobile-only" && platform.mobile) ||
(platformType === "desktop-only" && platform.desktop) ||
(platformType === "web-only" && platform.web);
if (!active) {
el.remove();
} else {
el.setAttribute("class", `platform-icon ${platformType} ${status}`);
}
}
if (
platform.mobile &&
!avatarWrapper.querySelector(".platform-icon.mobile-only")
) {
const mobileIcon = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
mobileIcon.setAttribute("class", `platform-icon mobile-only ${status}`);
mobileIcon.setAttribute("viewBox", "0 0 1000 1500");
mobileIcon.setAttribute("fill", "#43a25a");
mobileIcon.setAttribute("aria-label", "Mobile");
mobileIcon.setAttribute("width", "17");
mobileIcon.setAttribute("height", "17");
mobileIcon.innerHTML = `
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/>
`; `;
} else if (!platform.mobile && mobileIcon) { avatarWrapper.appendChild(mobileIcon);
mobileIcon.remove(); }
avatarWrapper.innerHTML += `<div class="status-indicator ${data.discord_status}"></div>`;
const updatedStatusIndicator =
avatarWrapper.querySelector(".status-indicator");
if (!updatedStatusIndicator) {
const statusDiv = document.createElement("div");
statusDiv.className = `status-indicator ${status}`;
avatarWrapper.appendChild(statusDiv);
} else {
updatedStatusIndicator.className = `status-indicator ${status}`;
} }
const custom = data.activities?.find((a) => a.type === 4); const custom = data.activities?.find((a) => a.type === 4);
if (customStatus && custom) { updateCustomStatus(custom);
let emojiHTML = "";
const emoji = custom.emoji; populateReadme(data);
if (emoji?.id) {
const emojiUrl = `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`; const filtered = data.activities
emojiHTML = `<img src="${emojiUrl}" alt="${emoji.name}" class="custom-emoji">`; ?.filter((a) => a.type !== 4)
} else if (emoji?.name) { ?.sort((a, b) => {
emojiHTML = `${emoji.name} `; const priority = { 2: 0, 1: 1, 3: 2 }; // Listening, Streaming, Watching ? should i keep this
} const aPriority = priority[a.type] ?? 99;
customStatus.innerHTML = `${emojiHTML}${custom.state}`; const bPriority = priority[b.type] ?? 99;
} return aPriority - bPriority;
});
const filtered = data.activities?.filter((a) => a.type !== 4);
const activityList = document.querySelector(".activities"); const activityList = document.querySelector(".activities");
const activitiesTitle = document.querySelector(".activity-header");
if (activityList) { if (activityList && activitiesTitle) {
activityList.innerHTML = "";
if (filtered?.length) { if (filtered?.length) {
activityList.innerHTML = filtered.map(buildActivityHTML).join(""); activityList.innerHTML = filtered.map(buildActivityHTML).join("");
activitiesTitle.classList.remove("hidden");
} else {
activityList.innerHTML = "";
activitiesTitle.classList.add("hidden");
} }
updateElapsedAndProgress(); updateElapsedAndProgress();
getAllNoAsset();
}
if (data.kv?.snow === "true") loadEffectScript("snow");
if (data.kv?.rain === "true") loadEffectScript("rain");
if (data.kv?.stars === "true") loadEffectScript("stars");
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.style.opacity = "0";
setTimeout(() => loadingOverlay.remove(), 500);
} }
} }
function updateCustomStatus(custom) {
const userInfoInner = document.querySelector(".user-info");
const customStatus = userInfoInner?.querySelector(".custom-status");
if (!userInfoInner) return;
if (custom) {
let emojiHTML = "";
if (custom.emoji?.id) {
const emojiUrl = `https://cdn.discordapp.com/emojis/${custom.emoji.id}.${custom.emoji.animated ? "gif" : "png"}`;
emojiHTML = `<img src="${emojiUrl}" alt="${custom.emoji.name}" class="custom-emoji">`;
} else if (custom.emoji?.name) {
emojiHTML = `${custom.emoji.name} `;
}
const html = `
<p class="custom-status">
${emojiHTML}${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
</p>
`;
if (customStatus) {
customStatus.outerHTML = html;
} else {
userInfoInner.insertAdjacentHTML("beforeend", html);
}
} else if (customStatus) {
customStatus.remove();
}
}
async function getAllNoAsset() {
const noAssetImages = document.querySelectorAll(
"img.activity-image.no-asset",
);
for (const img of noAssetImages) {
const name = img.dataset.name;
if (!name) continue;
try {
const res = await fetch(`/api/art/${encodeURIComponent(name)}`);
if (!res.ok) continue;
const { icon } = await res.json();
if (icon) {
img.src = icon;
img.classList.remove("no-asset");
img.parentElement.classList.remove("no-asset");
}
} catch (err) {
console.warn(`Failed to fetch fallback icon for "${name}"`, err);
}
}
}
function updateClanBadge(data) {
const userInfoInner = document.querySelector(".user-info-inner");
if (!userInfoInner) return;
const clan = data?.discord_user?.clan;
if (!clan || !clan.tag || !clan.identity_guild_id || !clan.badge) return;
const existing = userInfoInner.querySelector(".clan-badge");
if (existing) existing.remove();
const wrapper = document.createElement("div");
wrapper.className = "clan-badge";
const img = document.createElement("img");
img.src = `https://cdn.discordapp.com/clan-badges/${clan.identity_guild_id}/${clan.badge}`;
img.alt = "Clan Badge";
const span = document.createElement("span");
span.className = "clan-name";
span.textContent = clan.tag;
wrapper.appendChild(img);
wrapper.appendChild(span);
const usernameEl = userInfoInner.querySelector(".username");
if (usernameEl) {
usernameEl.insertAdjacentElement("afterend", wrapper);
} else {
userInfoInner.appendChild(wrapper);
}
}
if (instanceUri) {
if (!instanceUri.startsWith("http")) {
instanceUri = `https://${instanceUri}`;
}
const wsUri = instanceUri
.replace(/^http:/, "ws:")
.replace(/^https:/, "wss:")
.replace(/\/$/, "");
socket = new WebSocket(`${wsUri}/socket`);
}
if (badgeURL && badgeURL !== "null" && userId) {
if (!badgeURL.startsWith("http")) {
badgeURL = `https://${badgeURL}`;
}
if (!badgeURL.endsWith("/")) {
badgeURL += "/";
}
}
if (userId && instanceUri) {
let heartbeatInterval = null;
socket.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
if (payload.op === 1 && payload.d?.heartbeat_interval) {
heartbeatInterval = setInterval(() => {
socket.send(JSON.stringify({ op: 3 }));
}, payload.d.heartbeat_interval);
socket.send(
JSON.stringify({
op: 2,
d: {
subscribe_to_id: userId,
},
}),
);
}
if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") {
updatePresence(payload.d);
requestAnimationFrame(() => updateElapsedAndProgress());
}
});
socket.addEventListener("close", () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
});
}
updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000);

77
public/js/rain.js Normal file
View file

@ -0,0 +1,77 @@
const rainContainer = document.createElement("div");
rainContainer.style.position = "fixed";
rainContainer.style.top = "0";
rainContainer.style.left = "0";
rainContainer.style.width = "100vw";
rainContainer.style.height = "100vh";
rainContainer.style.pointerEvents = "none";
document.body.appendChild(rainContainer);
const maxRaindrops = 100;
const raindrops = [];
const mouse = { x: -100, y: -100 };
document.addEventListener("mousemove", (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
const getRaindropColor = () => {
const htmlTag = document.documentElement;
return htmlTag.getAttribute("data-theme") === "dark"
? "rgba(173, 216, 230, 0.8)"
: "rgba(70, 130, 180, 0.8)";
};
const createRaindrop = () => {
if (raindrops.length >= maxRaindrops) {
const oldestRaindrop = raindrops.shift();
rainContainer.removeChild(oldestRaindrop);
}
const raindrop = document.createElement("div");
raindrop.classList.add("raindrop");
raindrop.style.position = "absolute";
raindrop.style.width = "2px";
raindrop.style.height = `${Math.random() * 10 + 10}px`;
raindrop.style.background = getRaindropColor();
raindrop.style.borderRadius = "1px";
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
raindrop.style.opacity = Math.random() * 0.5 + 0.3;
raindrop.speed = Math.random() * 6 + 4;
raindrop.directionX = (Math.random() - 0.5) * 0.2;
raindrop.directionY = Math.random() * 0.5 + 0.8;
raindrops.push(raindrop);
rainContainer.appendChild(raindrop);
};
setInterval(createRaindrop, 50);
function updateRaindrops() {
raindrops.forEach((raindrop, index) => {
const rect = raindrop.getBoundingClientRect();
raindrop.style.left = `${rect.left + raindrop.directionX * raindrop.speed}px`;
raindrop.style.top = `${rect.top + raindrop.directionY * raindrop.speed}px`;
if (rect.top + rect.height >= window.innerHeight) {
rainContainer.removeChild(raindrop);
raindrops.splice(index, 1);
}
if (
rect.left > window.innerWidth ||
rect.top > window.innerHeight ||
rect.left < 0
) {
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
}
});
requestAnimationFrame(updateRaindrops);
}
updateRaindrops();

82
public/js/snow.js Normal file
View file

@ -0,0 +1,82 @@
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", (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) => {
const rect = snowflake.getBoundingClientRect();
const dx = rect.left + rect.width / 2 - mouse.x;
const dy = rect.top + rect.height / 2 - mouse.y;
const 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();

65
public/js/stars.js Normal file
View file

@ -0,0 +1,65 @@
const container = document.createElement("div");
container.style.position = "fixed";
container.style.top = "0";
container.style.left = "0";
container.style.width = "100vw";
container.style.height = "100vh";
container.style.pointerEvents = "none";
container.style.overflow = "hidden";
container.style.zIndex = "9999";
document.body.appendChild(container);
for (let i = 0; i < 60; i++) {
const star = document.createElement("div");
const size = Math.random() * 2 + 1;
star.style.position = "absolute";
star.style.width = `${size}px`;
star.style.height = `${size}px`;
star.style.background = "white";
star.style.borderRadius = "50%";
star.style.opacity = Math.random();
star.style.top = `${Math.random() * 100}vh`;
star.style.left = `${Math.random() * 100}vw`;
star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate ease-in-out`;
container.appendChild(star);
}
function createShootingStar() {
const star = document.createElement("div");
star.classList.add("shooting-star");
let x = Math.random() * window.innerWidth * 0.8;
let y = Math.random() * window.innerHeight * 0.3;
const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8;
const speed = 10;
const totalFrames = 60;
const deg = angle * (180 / Math.PI);
star.style.left = `${x}px`;
star.style.top = `${y}px`;
star.style.transform = `rotate(${deg}deg)`;
container.appendChild(star);
let frame = 0;
function animate() {
x += Math.cos(angle) * speed;
y += Math.sin(angle) * speed;
star.style.left = `${x}px`;
star.style.top = `${y}px`;
star.style.opacity = `${1 - frame / totalFrames}`;
frame++;
if (frame < totalFrames) {
requestAnimationFrame(animate);
} else {
container.removeChild(star);
}
}
animate();
}
setInterval(() => {
if (Math.random() < 0.3) createShootingStar();
}, 1000);

View file

@ -1,6 +0,0 @@
export function timestampToReadable(timestamp?: number): string {
const date: Date =
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
if (isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", "");
}

View file

@ -1,26 +0,0 @@
import { renderFile } from "ejs";
import { resolve } from "path";
export async function renderEjsTemplate(
viewName: string | string[],
data: EjsTemplateData,
headers?: Record<string, string | number | boolean>,
): Promise<Response> {
let templatePath: string;
if (Array.isArray(viewName)) {
templatePath = resolve("src", "views", ...viewName);
} else {
templatePath = resolve("src", "views", viewName);
}
if (!templatePath.endsWith(".ejs")) {
templatePath += ".ejs";
}
const html: string = await renderFile(templatePath, data);
return new Response(html, {
headers: { "Content-Type": "text/html", ...headers },
});
}

View file

@ -1,92 +0,0 @@
import { lanyardConfig } from "@config/environment";
import { fetch } from "bun";
import { marked } from "marked";
export async function getLanyardData(id?: string): Promise<LanyardResponse> {
let instance: string = lanyardConfig.instance;
if (instance.endsWith("/")) {
instance = instance.slice(0, -1);
}
if (!instance.startsWith("http://") && !instance.startsWith("https://")) {
instance = `https://${instance}`;
}
const url: string = `${instance}/v1/users/${id || lanyardConfig.userId}`;
const res: Response = await fetch(url, {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
if (!res.ok) {
return {
success: false,
error: {
code: "API_ERROR",
message: `Lanyard API responded with status ${res.status}`,
},
};
}
const data: LanyardResponse = (await res.json()) as LanyardResponse;
if (!data.success) {
return {
success: false,
error: {
code: "API_ERROR",
message: "Failed to fetch valid Lanyard data",
},
};
}
return data;
}
export async function handleReadMe(data: LanyardData): Promise<string | null> {
const userReadMe: string | null = data.kv?.readme;
if (
!userReadMe ||
!userReadMe.toLowerCase().endsWith("readme.md") ||
!userReadMe.startsWith("http")
) {
return null;
}
try {
const res: Response = await fetch(userReadMe, {
headers: {
Accept: "text/markdown",
},
});
const contentType: string = res.headers.get("content-type") || "";
if (
!res.ok ||
!(
contentType.includes("text/markdown") ||
contentType.includes("text/plain")
)
)
return null;
if (res.headers.has("content-length")) {
const size: number = parseInt(
res.headers.get("content-length") || "0",
10,
);
if (size > 1024 * 100) return null;
}
const text: string = await res.text();
if (!text || text.length < 10) return null;
return marked.parse(text);
} catch {
return null;
}
}

View file

@ -1,205 +0,0 @@
import { environment } from "@config/environment";
import { timestampToReadable } from "@helpers/char";
import type { Stats } from "fs";
import {
createWriteStream,
existsSync,
mkdirSync,
statSync,
WriteStream,
} from "fs";
import { EOL } from "os";
import { basename, join } from "path";
class Logger {
private static instance: Logger;
private static log: string = join(__dirname, "../../logs");
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
private writeToLog(logMessage: string): void {
if (environment.development) return;
const date: Date = new Date();
const logDir: string = Logger.log;
const logFile: string = join(
logDir,
`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`,
);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
let addSeparator: boolean = false;
if (existsSync(logFile)) {
const fileStats: Stats = statSync(logFile);
if (fileStats.size > 0) {
const lastModified: Date = new Date(fileStats.mtime);
if (
lastModified.getFullYear() === date.getFullYear() &&
lastModified.getMonth() === date.getMonth() &&
lastModified.getDate() === date.getDate() &&
lastModified.getHours() !== date.getHours()
) {
addSeparator = true;
}
}
}
const stream: WriteStream = createWriteStream(logFile, { flags: "a" });
if (addSeparator) {
stream.write(`${EOL}${date.toISOString()}${EOL}`);
}
stream.write(`${logMessage}${EOL}`);
stream.close();
}
private extractFileName(stack: string): string {
const stackLines: string[] = stack.split("\n");
let callerFile: string = "";
for (let i: number = 2; i < stackLines.length; i++) {
const line: string = stackLines[i].trim();
if (line && !line.includes("Logger.") && line.includes("(")) {
callerFile = line.split("(")[1]?.split(")")[0] || "";
break;
}
}
return basename(callerFile);
}
private getCallerInfo(stack: unknown): {
filename: string;
timestamp: string;
} {
const filename: string =
typeof stack === "string" ? this.extractFileName(stack) : "unknown";
const readableTimestamp: string = timestampToReadable();
return { filename, timestamp: readableTimestamp };
}
public info(message: string | string[], breakLine: boolean = false): void {
const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack);
const joinedMessage: string = Array.isArray(message)
? message.join(" ")
: message;
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: "[INFO]", color: "32" },
filename: { value: `(${filename})`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public warn(message: string | string[], breakLine: boolean = false): void {
const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack);
const joinedMessage: string = Array.isArray(message)
? message.join(" ")
: message;
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: "[WARN]", color: "33" },
filename: { value: `(${filename})`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public error(
message: string | Error | (string | Error)[],
breakLine: boolean = false,
): void {
const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack);
const messages: (string | Error)[] = Array.isArray(message)
? message
: [message];
const joinedMessage: string = messages
.map((msg: string | Error): string =>
typeof msg === "string" ? msg : msg.message,
)
.join(" ");
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: "[ERROR]", color: "31" },
filename: { value: `(${filename})`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public custom(
bracketMessage: string,
bracketMessage2: string,
message: string | string[],
color: string,
breakLine: boolean = false,
): void {
const stack: string = new Error().stack || "";
const { timestamp } = this.getCallerInfo(stack);
const joinedMessage: string = Array.isArray(message)
? message.join(" ")
: message;
const logMessageParts: ILogMessageParts = {
readableTimestamp: { value: timestamp, color: "90" },
level: { value: bracketMessage, color },
filename: { value: `${bracketMessage2}`, color: "36" },
message: { value: joinedMessage, color: "0" },
};
this.writeToLog(
`${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`,
);
this.writeConsoleMessageColored(logMessageParts, breakLine);
}
public space(): void {
console.log();
}
private writeConsoleMessageColored(
logMessageParts: ILogMessageParts,
breakLine: boolean = false,
): void {
const logMessage: string = Object.keys(logMessageParts)
.map((key: string) => {
const part: ILogMessagePart = logMessageParts[key];
return `\x1b[${part.color}m${part.value}\x1b[0m`;
})
.join(" ");
console.log(logMessage + (breakLine ? EOL : ""));
}
}
const logger: Logger = Logger.getInstance();
export { logger };

View file

@ -1,16 +1,15 @@
import { logger } from "@helpers/logger";
import { serverHandler } from "@/server"; import { serverHandler } from "@/server";
import { logger } from "@creations.works/logger";
async function main(): Promise<void> { async function main(): Promise<void> {
try { serverHandler.initialize();
serverHandler.initialize();
} catch (error) {
throw error;
}
} }
main().catch((error: Error) => { main().catch((error: Error) => {
logger.error(["Error initializing the server:", error]); logger.error(["Error initializing the server:", error]);
process.exit(1); process.exit(1);
}); });
if (process.env.IN_PTERODACTYL === "true") {
console.log("Server Started");
}

View file

@ -1,6 +1,6 @@
import { lanyardConfig } from "@config/environment"; import { resolve } from "node:path";
import { renderEjsTemplate } from "@helpers/ejs"; import { badgeApi, lanyardConfig, plausibleScript } from "@config/environment";
import { getLanyardData, handleReadMe } from "@helpers/lanyard"; import { file } from "bun";
const routeDef: RouteDef = { const routeDef: RouteDef = {
method: "GET", method: "GET",
@ -10,44 +10,32 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
const { id } = request.params; const { id } = request.params;
const data: LanyardResponse = await getLanyardData(id); const instance = lanyardConfig.instance
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
if (!data.success) { const path = resolve("src", "views", "index.html");
return await renderEjsTemplate("error", { const bunFile = file(path);
message: data.error.message,
});
}
let instance: string = lanyardConfig.instance; const html = new HTMLRewriter()
.on("head", {
element(head) {
head.setAttribute("data-user-id", id || lanyardConfig.userId);
head.setAttribute("data-instance-uri", instance);
head.setAttribute("data-badge-url", badgeApi || "");
if (instance.endsWith("/")) { if (plausibleScript) {
instance = instance.slice(0, -1); head.append(plausibleScript, { html: true });
} }
},
})
.transform(await bunFile.text());
if (instance.startsWith("http://") || instance.startsWith("https://")) { return new Response(html, {
instance = instance.slice(instance.indexOf("://") + 3); headers: {
} "Content-Type": "text/html",
const presence: LanyardData = data.data;
const readme: string | Promise<string> | null =
await handleReadMe(presence);
const ejsTemplateData: EjsTemplateData = {
title: `${presence.discord_user.username || "Unknown"}`,
username: presence.discord_user.username,
status: presence.discord_status,
activities: presence.activities,
user: presence.discord_user,
platform: {
desktop: presence.active_on_discord_desktop,
mobile: presence.active_on_discord_mobile,
web: presence.active_on_discord_web,
}, },
instance, });
readme,
};
return await renderEjsTemplate("index", ejsTemplateData);
} }
export { handler, routeDef }; export { handler, routeDef };

View file

@ -0,0 +1,93 @@
import { redisTtl, steamGridDbKey } from "@config/environment";
import { redis } from "bun";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
log: false,
};
async function fetchSteamGridIcon(gameName: string): Promise<string | null> {
const cacheKey = `steamgrid:icon:${gameName.toLowerCase()}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const search = await fetch(
`https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameName)}`,
{
headers: {
Authorization: `Bearer ${steamGridDbKey}`,
},
},
);
if (!search.ok) return null;
const { data } = await search.json();
if (!data?.length) return null;
const gameId = data[0]?.id;
if (!gameId) return null;
const iconRes = await fetch(
`https://www.steamgriddb.com/api/v2/icons/game/${gameId}`,
{
headers: {
Authorization: `Bearer ${steamGridDbKey}`,
},
},
);
if (!iconRes.ok) return null;
const iconData = await iconRes.json();
const icon = iconData?.data?.[0]?.url ?? null;
if (icon) {
await redis.set(cacheKey, icon);
await redis.expire(cacheKey, redisTtl);
}
return icon;
}
async function handler(request: ExtendedRequest): Promise<Response> {
if (!steamGridDbKey) {
return Response.json(
{
status: 503,
error: "Route disabled due to missing SteamGridDB key",
},
{ status: 503 },
);
}
const { game } = request.params;
if (!game || typeof game !== "string" || game.length < 2) {
return Response.json(
{ status: 400, error: "Missing or invalid game name" },
{ status: 400 },
);
}
const icon = await fetchSteamGridIcon(game);
if (!icon) {
return Response.json(
{ status: 404, error: "Icon not found" },
{ status: 404 },
);
}
return Response.json(
{
status: 200,
game,
icon,
},
{ status: 200 },
);
}
export { handler, routeDef };

View file

@ -1,69 +0,0 @@
import { fetch } from "bun";
import { Vibrant } from "node-vibrant/node";
type Palette = Awaited<ReturnType<typeof Vibrant.prototype.getPalette>>;
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
if (!url) {
return Response.json({ error: "URL is required" }, { status: 400 });
}
if (typeof url !== "string" || !url.startsWith("http")) {
return Response.json({ error: "Invalid URL" }, { status: 400 });
}
let res: Response;
try {
res = await fetch(url);
} catch {
return Response.json(
{ error: "Failed to fetch image" },
{ status: 500 },
);
}
if (!res.ok) {
return Response.json(
{ error: "Image fetch returned error" },
{ status: res.status },
);
}
const type: string | null = res.headers.get("content-type");
if (!type?.startsWith("image/")) {
return Response.json({ error: "Not an image" }, { status: 400 });
}
const buffer: Buffer = Buffer.from(await res.arrayBuffer());
const base64: string = buffer.toString("base64");
const colors: Palette = await Vibrant.from(buffer).getPalette();
const payload: {
img: string;
colors: Palette;
} = {
img: `data:${type};base64,${base64}`,
colors,
};
const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(payload));
return new Response(compressed, {
headers: {
"Content-Type": "application/json",
"Content-Encoding": "gzip",
"Cache-Control": "public, max-age=31536000, immutable",
"Access-Control-Allow-Origin": "*",
},
});
}
export { handler, routeDef };

85
src/routes/api/css.ts Normal file
View file

@ -0,0 +1,85 @@
import { redisTtl } from "@config/environment";
import { fetch } from "bun";
import { redis } from "bun";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "*/*",
log: false,
};
async function fetchAndCacheCss(url: string): Promise<string | null> {
const cacheKey = `css:${url}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const res = await fetch(url, {
headers: {
Accept: "text/css",
},
});
if (!res.ok) return null;
if (res.headers.has("content-length")) {
const size = Number.parseInt(res.headers.get("content-length") || "0", 10);
if (size > 1024 * 50) return null;
}
const text = await res.text();
if (!text || text.length < 5) return null;
const sanitized = text
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/@import\s+url\(['"]?(.*?)['"]?\);?/gi, "");
await redis.set(cacheKey, sanitized);
await redis.expire(cacheKey, redisTtl);
return sanitized;
}
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
if (!url || !url.startsWith("http") || !/\.css$/i.test(url)) {
return Response.json(
{
success: false,
error: {
code: "INVALID_URL",
message: "Invalid URL provided",
},
},
{ status: 400 },
);
}
const sanitized = await fetchAndCacheCss(url);
if (!sanitized) {
return Response.json(
{
success: false,
error: {
code: "FETCH_FAILED",
message: "Failed to fetch or sanitize CSS",
},
},
{ status: 400 },
);
}
return new Response(sanitized, {
headers: {
"Content-Type": "text/css",
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
},
status: 200,
});
}
export { handler, routeDef };

139
src/routes/api/readme.ts Normal file
View file

@ -0,0 +1,139 @@
import { redisTtl } from "@config/environment";
import { fetch, redis } from "bun";
import { marked } from "marked";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "*/*",
log: false,
};
async function addLazyLoading(html: string): Promise<string> {
return new HTMLRewriter()
.on("img", {
element(el) {
el.setAttribute("loading", "lazy");
},
})
.transform(html);
}
async function sanitizeHtml(html: string): Promise<string> {
return new HTMLRewriter()
.on(
"script, iframe, object, embed, link[rel=import], svg, math, base, meta[http-equiv='refresh']",
{
element(el) {
el.remove();
},
},
)
.on("*", {
element(el) {
for (const [name, value] of el.attributes) {
const lowerName = name.toLowerCase();
const lowerValue = value.toLowerCase();
if (lowerName.startsWith("on")) {
el.removeAttribute(name);
}
if (
(lowerName === "href" ||
lowerName === "src" ||
lowerName === "action") &&
(lowerValue.startsWith("javascript:") ||
lowerValue.startsWith("data:"))
) {
el.removeAttribute(name);
}
}
},
})
.on("img", {
element(el) {
el.setAttribute("loading", "lazy");
},
})
.transform(html);
}
async function fetchAndCacheReadme(url: string): Promise<string | null> {
const cacheKey = `readme:${url}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const res = await fetch(url, {
headers: {
Accept: "text/markdown",
},
});
if (!res.ok) return null;
if (res.headers.has("content-length")) {
const size = Number.parseInt(res.headers.get("content-length") || "0", 10);
if (size > 1024 * 100) return null;
}
const text = await res.text();
if (!text || text.length < 10) return null;
const html = /\.(html?|htm)$/i.test(url) ? text : await marked.parse(text);
const safe = await sanitizeHtml(html);
await redis.set(cacheKey, safe);
await redis.expire(cacheKey, redisTtl);
return safe;
}
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
if (
!url ||
!url.startsWith("http") ||
!/\.(md|markdown|txt|html?)$/i.test(url)
) {
return Response.json(
{
success: false,
error: {
code: "INVALID_URL",
message: "Invalid URL provided",
},
},
{ status: 400 },
);
}
const safe = await fetchAndCacheReadme(url);
if (!safe) {
return Response.json(
{
success: false,
error: {
code: "FETCH_FAILED",
message: "Failed to fetch or process file",
},
},
{ status: 400 },
);
}
return new Response(safe, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
},
status: 200,
});
}
export { handler, routeDef };

View file

@ -1,54 +1,13 @@
import { lanyardConfig } from "@config/environment"; import { handler as idHandler, routeDef as idRouteDef } from "./[id]";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
const routeDef: RouteDef = { export const routeDef = {
method: "GET", ...idRouteDef,
accepts: "*/*",
returns: "text/html",
}; };
async function handler(): Promise<Response> { export const handler = async (
const data: LanyardResponse = await getLanyardData(); request: ExtendedRequest,
body: unknown,
if (!data.success) { server: BunServer,
return await renderEjsTemplate("error", { ) => {
message: data.error.message, return await idHandler(request);
}); };
}
let instance: string = lanyardConfig.instance;
if (instance.endsWith("/")) {
instance = instance.slice(0, -1);
}
if (instance.startsWith("http://") || instance.startsWith("https://")) {
instance = instance.slice(instance.indexOf("://") + 3);
}
const presence: LanyardData = data.data;
const readme: string | Promise<string> | null =
await handleReadMe(presence);
const ejsTemplateData: EjsTemplateData = {
title:
presence.discord_user.global_name || presence.discord_user.username,
username:
presence.discord_user.global_name || presence.discord_user.username,
status: presence.discord_status,
activities: presence.activities,
user: presence.discord_user,
platform: {
desktop: presence.active_on_discord_desktop,
mobile: presence.active_on_discord_mobile,
web: presence.active_on_discord_web,
},
instance,
readme,
};
return await renderEjsTemplate("index", ejsTemplateData);
}
export { handler, routeDef };

View file

@ -1,12 +1,12 @@
import { resolve } from "node:path";
import { environment } from "@config/environment"; import { environment } from "@config/environment";
import { logger } from "@helpers/logger"; import { logger } from "@creations.works/logger";
import { import {
type BunFile, type BunFile,
FileSystemRouter, FileSystemRouter,
type MatchedRoute, type MatchedRoute,
type Serve, type Serve,
} from "bun"; } from "bun";
import { resolve } from "path";
import { webSocketHandler } from "@/websocket"; import { webSocketHandler } from "@/websocket";
@ -34,23 +34,19 @@ class ServerHandler {
open: webSocketHandler.handleOpen.bind(webSocketHandler), open: webSocketHandler.handleOpen.bind(webSocketHandler),
message: webSocketHandler.handleMessage.bind(webSocketHandler), message: webSocketHandler.handleMessage.bind(webSocketHandler),
close: webSocketHandler.handleClose.bind(webSocketHandler), close: webSocketHandler.handleClose.bind(webSocketHandler),
error(error) {
logger.error(`Server error: ${error.message}`);
return new Response(`Server Error: ${error.message}`, {
status: 500,
});
},
}, },
}); });
const accessUrls = [ const accessUrls: string[] = [
`http://${server.hostname}:${server.port}`, `http://${server.hostname}:${server.port}`,
`http://localhost:${server.port}`, `http://localhost:${server.port}`,
`http://127.0.0.1:${server.port}`, `http://127.0.0.1:${server.port}`,
]; ];
logger.info(`Server running at ${accessUrls[0]}`, true); logger.info(`Server running at ${accessUrls[0]}`);
logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, {
breakLine: true,
});
this.logRoutes(); this.logRoutes();
} }
@ -83,21 +79,16 @@ class ServerHandler {
if (await file.exists()) { if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer(); const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = const contentType: string = file.type || "application/octet-stream";
file.type || "application/octet-stream";
return new Response(fileContent, { return new Response(fileContent, {
headers: { "Content-Type": contentType }, headers: { "Content-Type": contentType },
}); });
} else {
logger.warn(`File not found: ${filePath}`);
return new Response("Not Found", { status: 404 });
} }
logger.warn(`File not found: ${filePath}`);
return new Response("Not Found", { status: 404 });
} catch (error) { } catch (error) {
logger.error([ logger.error([`Error serving static file: ${pathname}`, error as Error]);
`Error serving static file: ${pathname}`,
error as Error,
]);
return new Response("Internal Server Error", { status: 500 }); return new Response("Internal Server Error", { status: 500 });
} }
} }
@ -118,17 +109,20 @@ class ServerHandler {
let requestBody: unknown = {}; let requestBody: unknown = {};
let response: Response; let response: Response;
let logRequest = true;
if (match) { if (match) {
const { filePath, params, query } = match; const { filePath, params, query } = match;
try { try {
const routeModule: RouteModule = await import(filePath); const routeModule: RouteModule = await import(filePath);
const contentType: string | null = const contentType: string | null = request.headers.get("Content-Type");
request.headers.get("Content-Type");
const actualContentType: string | null = contentType const actualContentType: string | null = contentType
? contentType.split(";")[0].trim() ? contentType.split(";")[0].trim()
: null; : null;
logRequest = routeModule.routeDef.log !== false;
if ( if (
routeModule.routeDef.needsBody === "json" && routeModule.routeDef.needsBody === "json" &&
actualContentType === "application/json" actualContentType === "application/json"
@ -151,9 +145,7 @@ class ServerHandler {
if ( if (
(Array.isArray(routeModule.routeDef.method) && (Array.isArray(routeModule.routeDef.method) &&
!routeModule.routeDef.method.includes( !routeModule.routeDef.method.includes(request.method)) ||
request.method,
)) ||
(!Array.isArray(routeModule.routeDef.method) && (!Array.isArray(routeModule.routeDef.method) &&
routeModule.routeDef.method !== request.method) routeModule.routeDef.method !== request.method)
) { ) {
@ -178,9 +170,7 @@ class ServerHandler {
if (Array.isArray(expectedContentType)) { if (Array.isArray(expectedContentType)) {
matchesAccepts = matchesAccepts =
expectedContentType.includes("*/*") || expectedContentType.includes("*/*") ||
expectedContentType.includes( expectedContentType.includes(actualContentType || "");
actualContentType || "",
);
} else { } else {
matchesAccepts = matchesAccepts =
expectedContentType === "*/*" || expectedContentType === "*/*" ||
@ -219,10 +209,7 @@ class ServerHandler {
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
logger.error([ logger.error([`Error handling route ${request.url}:`, error as Error]);
`Error handling route ${request.url}:`,
error as Error,
]);
response = Response.json( response = Response.json(
{ {
@ -244,28 +231,30 @@ class ServerHandler {
); );
} }
const headers: Headers = response.headers; if (logRequest) {
let ip: string | null = server.requestIP(request)?.address || null; const headers = request.headers;
let ip = server.requestIP(request)?.address;
if (!ip) { if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip = ip =
headers.get("CF-Connecting-IP") || headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP") || headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For") || headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
null; "unknown";
}
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
} }
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
return response; return response;
} }
} }

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Error</title>
<link rel="stylesheet" href="/public/css/error.css">
<meta name="color-scheme" content="dark">
</head>
<body>
<div class="error-container">
<div class="error-title">Something went wrong</div>
<div class="error-message">
<%= message || "An unexpected error occurred." %>
</div>
</div>
</body>
</html>

View file

@ -1,150 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head data-user-id="<%= user.id %>" data-instance-uri="<%= instance %>">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="<%= username %>'s Presence">
<meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>">
<meta property="og:description" content="<%= activities[0]?.state || 'Discord Presence' %>">
<title><%= title %></title>
<link rel="stylesheet" href="/public/css/index.css">
<script src="/public/js/index.js" defer></script>
<meta name="color-scheme" content="dark">
</head>
<body>
<div class="user-card">
<div class="avatar-status-wrapper">
<div class="avatar-wrapper">
<img class="avatar" src="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>" alt="Avatar">
<% if (user.avatar_decoration_data) { %>
<img class="decoration" src="https://cdn.discordapp.com/avatar-decoration-presets/<%= user.avatar_decoration_data.asset %>" alt="Decoration">
<% } %>
<% if (platform.mobile) { %>
<svg class="platform-icon mobile-only" viewBox="0 0 1000 1500" fill="#43a25a" aria-label="Mobile" width="17" height="17">
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/>
</svg>
<% } else { %>
<div class="status-indicator <%= status %>"></div>
<% } %>
</div>
<div class="user-info">
<h1><%= username %></h1>
<% if (activities.length && activities[0].type === 4) {
const emoji = activities[0].emoji;
const isCustom = emoji?.id;
const emojiUrl = isCustom
? `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`
: null;
%>
<p class="custom-status">
<% if (isCustom && emojiUrl) { %>
<img src="<%= emojiUrl %>" alt="<%= emoji.name %>" class="custom-emoji">
<% } else if (emoji?.name) { %>
<%= emoji.name %>
<% } %>
<%= activities[0].state %>
</p>
<% } %>
</div>
</div>
</div>
<% const filtered = activities.filter(a => a.type !== 4); %>
<% if (filtered.length > 0) { %>
<h2>Activities</h2>
<ul class="activities">
<% filtered.forEach(activity => {
const start = activity.timestamps?.start;
const end = activity.timestamps?.end;
const now = Date.now();
const elapsed = start ? now - start : 0;
const total = (start && end) ? end - start : null;
const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null;
const img = activity.assets?.large_image;
let art = null;
if (img?.includes("https")) {
const clean = img.split("/https/")[1];
if (clean) art = `https://${clean}`;
} else if (img?.startsWith("spotify:")) {
art = `https://i.scdn.co/image/${img.split(":")[1]}`;
} else if (img) {
art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`;
}
%>
<li class="activity">
<% if (art) { %>
<img class="activity-art" src="<%= art %>" alt="Art">
<% } %>
<div class="activity-content">
<div class="activity-header <%= progress !== null ? 'no-timestamp' : '' %>">
<span class="activity-name"><%= activity.name %></span>
<% if (start && progress === null) { %>
<div class="activity-timestamp" data-start="<%= start %>">
<% const started = new Date(start); %>
<span>
Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> <span class="elapsed"></span>
</span>
</div>
<% } %>
</div>
<% if (activity.details) { %>
<div class="activity-detail"><%= activity.details %></div>
<% } %>
<% if (activity.state) { %>
<div class="activity-detail"><%= activity.state %></div>
<% } %>
<% if (activity.buttons && activity.buttons.length > 0) { %>
<div class="activity-buttons">
<% activity.buttons.forEach((button, index) => {
const buttonLabel = typeof button === 'string' ? button : button.label;
let buttonUrl = null;
if (typeof button === 'object' && button.url) {
buttonUrl = button.url;
}
else if (index === 0 && activity.url) {
buttonUrl = activity.url;
}
%>
<% if (buttonUrl) { %>
<a href="<%= buttonUrl %>" class="activity-button" target="_blank" rel="noopener noreferrer"><%= buttonLabel %></a>
<% } else { %>
<span class="activity-button disabled"><%= buttonLabel %></span>
<% } %>
<% }); %>
</div>
<% } %>
<% if (progress !== null) { %>
<div class="progress-bar" data-start="<%= start %>" data-end="<%= end %>">
<div class="progress-fill" <%= progress !== null ? `style="width: ${progress}%"` : '' %>></div>
</div>
<% if (start && end) { %>
<div class="progress-time-labels" data-start="<%= start %>" data-end="<%= end %>">
<span class="progress-current">--:--</span>
<span class="progress-total"><%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %></span>
</div>
<% } %>
<% } %>
</div>
</li>
<% }) %>
</ul>
<% } %>
<% if (readme) { %>
<h2>Readme</h2>
<section class="readme">
<div class="markdown-body"><%- readme %></div>
</section>
<% } %>
</body>
</html>

67
src/views/index.html Normal file
View file

@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="View real-time Discord presence, activities, and badges with open-source integration." />
<meta name="color-scheme" content="dark" />
<title>Discord Presence</title>
<link rel="icon" id="site-icon" type="image/png" href="/public/assets/favicon.png" />
<link rel="stylesheet" href="/public/css/index.css" />
<link rel="stylesheet" href="/public/css/root.css" />
</head>
<body>
<div id="loading-overlay" role="status" aria-live="polite">
<div class="loading-spinner"></div>
</div>
<header>
<a
href="https://git.creations.works/creations/profilePage"
target="_blank"
rel="noopener noreferrer"
title="View source code on Forgejo"
>
<img
class="open-source-logo"
src="/public/assets/forgejo_logo.svg"
alt="Forgejo open-source logo"
style="opacity: 0.5"
loading="lazy"
/>
</a>
</header>
<main>
<section class="user-card">
<div class="avatar-status-wrapper">
<div class="avatar-wrapper">
<img class="avatar hidden"/>
<div class="status-indicator offline hidden"></div>
</div>
<div class="user-info">
<div class="user-info-inner">
<h1 class="username"></h1>
</div>
</div>
</div>
</section>
<section id="badges" class="badges hidden" aria-label="User Badges"></section>
<section aria-label="Discord Activities" class="activities-section">
<h2 class="activity-header hidden">Activities</h2>
<ul class="activities"></ul>
</section>
<section class="readme hidden" aria-label="Profile README">
<div class="markdown-body"></div>
</section>
</main>
<script src="/public/js/index.js" type="module"></script>
</body>
</html>

View file

@ -1,5 +1,5 @@
import { logger } from "@helpers/logger"; import { logger } from "@creations.works/logger";
import { type ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
class WebSocketHandler { class WebSocketHandler {
public handleMessage(ws: ServerWebSocket, message: string): void { public handleMessage(ws: ServerWebSocket, message: string): void {
@ -20,11 +20,7 @@ class WebSocketHandler {
} }
} }
public handleClose( public handleClose(ws: ServerWebSocket, code: number, reason: string): void {
ws: ServerWebSocket,
code: number,
reason: string,
): void {
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
} }
} }

View file

@ -2,28 +2,14 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": [ "@/*": ["src/*"],
"src/*" "@config/*": ["config/*"],
], "@types/*": ["types/*"],
"@config/*": [ "@helpers/*": ["src/helpers/*"]
"config/*"
],
"@types/*": [
"types/*"
],
"@helpers/*": [
"src/helpers/*"
]
}, },
"typeRoots": [ "typeRoots": ["./src/types", "./node_modules/@types"],
"./src/types",
"./node_modules/@types"
],
// Enable latest features // Enable latest features
"lib": [ "lib": ["ESNext", "DOM"],
"ESNext",
"DOM"
],
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "moduleDetection": "force",
@ -41,11 +27,7 @@
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false
}, },
"include": [ "include": ["src", "types", "config"]
"src",
"types",
"config"
],
} }

3
types/ejs.d.ts vendored
View file

@ -1,3 +0,0 @@
interface EjsTemplateData {
[key: string]: string | number | boolean | object | undefined | null;
}

72
types/lanyard.d.ts vendored
View file

@ -1,72 +0,0 @@
interface DiscordUser {
id: string;
username: string;
avatar: string;
discriminator: string;
clan?: string | null;
avatar_decoration_data?: {
sku_id: string;
asset: string;
expires_at: string | null;
};
bot: boolean;
global_name: string;
primary_guild?: string | null;
collectibles?: {
enabled: boolean;
disabled: boolean;
};
display_name: string;
public_flags: number;
}
interface Activity {
id: string;
name: string;
type: number;
state: string;
created_at: number;
}
interface SpotifyData {
track_id: string;
album_id: string;
album_name: string;
artist_name: string;
track_name: string;
}
interface Kv {
[key: string]: string;
}
interface LanyardData {
kv: Kv;
discord_user: DiscordUser;
activities: Activity[];
discord_status: string;
active_on_discord_web: boolean;
active_on_discord_desktop: boolean;
active_on_discord_mobile: boolean;
listening_to_spotify?: boolean;
spotify?: SpotifyData;
spotify_status: string;
active_on_spotify: boolean;
active_on_xbox: boolean;
active_on_playstation: boolean;
}
type LanyardSuccess = {
success: true;
data: LanyardData;
};
type LanyardError = {
success: false;
error: {
code: string;
message: string;
};
};
type LanyardResponse = LanyardSuccess | LanyardError;

9
types/logger.d.ts vendored
View file

@ -1,9 +0,0 @@
type ILogMessagePart = { value: string; color: string };
type ILogMessageParts = {
level: ILogMessagePart;
filename: ILogMessagePart;
readableTimestamp: ILogMessagePart;
message: ILogMessagePart;
[key: string]: ILogMessagePart;
};

1
types/routes.d.ts vendored
View file

@ -3,6 +3,7 @@ type RouteDef = {
accepts: string | null | string[]; accepts: string | null | string[];
returns: string; returns: string;
needsBody?: "multipart" | "json"; needsBody?: "multipart" | "json";
log?: boolean;
}; };
type RouteModule = { type RouteModule = {