Compare commits

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

63 commits
dev ... main

Author SHA1 Message Date
9d6b9e40a7
fix missing av decoration, missed when moving to js 2025-05-16 18:53:15 -04:00
bf52c02122
remove uneeded 2025-05-14 10:36:19 -04:00
fca71334e9
fix listening 2025-05-14 10:35:46 -04:00
11ab56b9b3
forgot readme 2025-05-12 18:52:43 -04:00
a25aff0e24
forgot to stage 2025-05-12 18:49:53 -04:00
aa24c979ee
add robots txt route, verifiy required env vars func, fix issue with reviewdb badges and emojis 2025-05-12 18:49:42 -04:00
dbdb59f48b
fix the scroll and "page" logic 2025-05-10 13:05:10 -04:00
453a79a4e4
to 24h 2025-05-10 12:54:57 -04:00
87b09af73e
forgot readme 2025-05-10 12:51:11 -04:00
9aa58ae23f
add review db, fix issues with spamming css url and readme whenever status updated 2025-05-10 12:46:58 -04:00
5ad5d7181f
add option for users to opt out 2025-05-07 15:21:39 -04:00
ba67ba55e3
fix issue with indef loading for unkown users 2025-05-07 15:18:37 -04:00
784330b5a6
fix rain,snow,stars, activity title 2025-05-04 11:28:40 -04:00
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' (#7) from KrstlSkll69/profilePage:main into main
Reviewed-on: creations/profilePage#7
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
42 changed files with 2083 additions and 1250 deletions

View file

@ -2,6 +2,22 @@
HOST=0.0.0.0
PORT=8080
REDIS_URL=redis://dragonfly:6379
REDIS_TTL=3600 # seconds
# this is only the default value if non is give in /id
LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081
# Required if you want to enable reviews from reviewdb
REVIEW_DB=true
# 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=''

4
.gitignore vendored
View file

@ -1,4 +1,6 @@
/node_modules
bun.lock
.env
logs/
logs/
.vscode/
robots.txt

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" ]

41
LICENSE
View file

@ -1,21 +1,28 @@
MIT License
BSD 3-Clause License
Copyright (c) 2025 [creations.works]
Copyright (c) 2025, creations.works
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
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.

114
README.md
View file

@ -1,12 +1,36 @@
# Discord Profile Page
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS.
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun.
## Prerequisite: Lanyard Backend
# Preview
https://creations.works
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance for Discord presence data.
---
Make sure Lanyard is running and accessible before using this profile page.
## 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
---
@ -28,35 +52,64 @@ Copy the example environment file and update it:
cp .env.example .env
```
#### Required `.env` Variables
#### `.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`) |
| `LANYARD_USER_ID` | Your Discord user ID |
| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL |
| 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)) |
| `REVIEW_DB` | Enables showing reviews from reviewdb on user pages |
| `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons |
| `ROBOTS_FILE` | If there it uses the file in /robots.txt route, requires a valid path |
#### Optional Lanyard KV Vars (per-user customization)
#### Optional Lanyard KV Variables (per-user customization)
These are expected to be defined in Lanyard's KV store:
These can be defined in Lanyard's KV store to customize the page:
| Variable | Description |
|-----------|-------------------------------------------------------------|
| `snow` | Enables snow background effect (`true`) |
| `rain` | Enables rain background effect (`true`) |
| `readme` | URL to a README file displayed on your profile |
| `colors` | Enables avatar-based color theme (uses `node-vibrant`) |
| 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 |
| `optout` | Allows users to stop sharing there profile on the website (`true` / `false`) |
| `reviews` | Enables reviews from reviewdb (`true` / `false`) |
---
### 3. Start the App
### 3. Start the Instance
```bash
bun run start
```
Then open `http://localhost:8080` in your browser.
---
## 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`.
---
@ -68,20 +121,23 @@ Then open `http://localhost:8080` in your browser.
docker compose up -d --build
```
Make sure your `.env` file is correctly configured before starting.
Make sure the `.env` file is configured correctly before starting the container.
---
## Tech Stack
## Routes
- Bun Runtime
- EJS Templating
- CSS Styling
- node-vibrant Avatar color extraction
- Biome.js Linting and formatting
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
[MIT](/LICENSE)
[BSD 3](LICENSE)

View file

@ -26,7 +26,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
},
"javascript": {

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,11 +1,69 @@
export const environment: Environment = {
import { resolve } from "node:path";
import { logger } from "@creations.works/logger";
const environment: Environment = {
port: Number.parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0",
development:
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
export const lanyardConfig: LanyardConfig = {
const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
instance: process.env.LANYARD_INSTANCE || "",
};
const reviewDb = {
enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1",
url: "https://manti.vendicated.dev/api/reviewdb",
};
const badgeApi: string | null = process.env.BADGE_API_URL || null;
const steamGridDbKey: string | undefined = process.env.STEAMGRIDDB_API_KEY;
const plausibleScript: string | null =
process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null;
const robotstxtPath: string | null = process.env.ROBOTS_FILE
? resolve(process.env.ROBOTS_FILE)
: null;
function verifyRequiredVariables(): void {
const requiredVariables = [
"HOST",
"PORT",
"LANYARD_USER_ID",
"LANYARD_INSTANCE",
];
let hasError = false;
for (const key of requiredVariables) {
const value = process.env[key];
if (value === undefined || value.trim() === "") {
logger.error(`Missing or empty environment variable: ${key}`);
hasError = true;
}
}
if (hasError) {
process.exit(1);
}
}
export {
environment,
lanyardConfig,
redisTtl,
reviewDb,
badgeApi,
steamGridDbKey,
plausibleScript,
robotstxtPath,
verifyRequiredVariables,
};

View file

@ -12,16 +12,13 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.8",
"@types/ejs": "^3.1.5",
"globals": "^16.0.0"
},
"peerDependencies": {
"typescript": "^5.8.3"
},
"dependencies": {
"ejs": "^3.1.10",
"isomorphic-dompurify": "^2.23.0",
"marked": "^15.0.7",
"node-vibrant": "^4.0.3"
"@creations.works/logger": "^1.0.3",
"marked": "^15.0.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 168 KiB

BIN
public/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  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

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,25 +0,0 @@
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 90vh;
background: #0e0e10;
color: #fff;
font-family: system-ui, sans-serif;
}
.error-container {
text-align: center;
padding: 2rem;
background: #1a1a1d;
border-radius: 12px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}
.error-title {
font-size: 2rem;
margin-bottom: 1rem;
color: #ff4e4e;
}
.error-message {
font-size: 1.2rem;
opacity: 0.8;
}

View file

@ -1,3 +1,79 @@
.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 {
font-family: system-ui, sans-serif;
background-color: var(--background);
@ -9,24 +85,37 @@ body {
align-items: center;
}
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
main {
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.raindrop {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
.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;
display: none !important;
}
.activity-header.hidden {
@ -38,14 +127,17 @@ body {
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
max-width: 600px;
max-width: 700px;
width: 100%;
}
.avatar-status-wrapper {
display: flex;
align-items: center;
gap: 1.5rem;
gap: 2rem;
width: fit-content;
max-width: 700px;
}
.avatar-wrapper {
@ -60,12 +152,35 @@ body {
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 {
position: absolute;
top: -18px;
left: -18px;
width: 164px;
height: 164px;
top: -13px;
left: -16px;
width: 160px;
height: 160px;
pointer-events: none;
}
@ -104,11 +219,30 @@ body {
.platform-icon.mobile-only {
position: absolute;
bottom: 4px;
bottom: 0;
right: 4px;
width: 30px;
height: 30px;
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 {
@ -116,6 +250,56 @@ body {
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 {
font-size: 2.5rem;
margin: 0;
@ -152,7 +336,26 @@ ul {
list-style: none;
padding: 0;
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-section .activity-block-header {
margin: 1rem 0 .5rem;
font-size: 2rem;
font-weight: 600;
text-align: center;
}
.activities {
@ -160,7 +363,8 @@ ul {
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 600px;
max-width: 700px;
box-sizing: border-box;
padding: 0;
margin: 0;
}
@ -173,10 +377,12 @@ ul {
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid var(--border-color);
}
.activity:hover {
background: var(--card-hover-bg);
transition: background-color 0.3s ease;
&:hover {
background: var(--card-hover-bg);
}
}
.activity-wrapper {
@ -197,6 +403,10 @@ ul {
height: 80px;
}
.no-asset {
display: none !important;
}
.activity-image-small {
width: 25px;
height: 25px;
@ -204,6 +414,7 @@ ul {
object-fit: cover;
flex-shrink: 0;
border-color: var(--card-bg);
background-color: var(--card-bg);
border-width: 2px;
border-style: solid;
@ -304,7 +515,7 @@ ul {
text-transform: uppercase;
font-weight: 600;
color: var(--blockquote-color);
margin-bottom: 0.50rem;
margin-bottom: 0.5rem;
display: block;
}
@ -335,17 +546,17 @@ ul {
text-decoration: none;
transition: background-color 0.2s ease;
display: inline-block;
}
.activity-button:hover {
background-color: var(--button-hover-bg);
text-decoration: none;
}
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;
}
.activity-button:disabled {
background-color: var(--button-disabled-bg);
cursor: not-allowed;
opacity: 0.8;
&:disabled {
background-color: var(--button-disabled-bg);
cursor: not-allowed;
opacity: 0.8;
}
}
@media (max-width: 600px) {
@ -362,6 +573,16 @@ ul {
.user-card {
width: 100%;
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 {
@ -494,14 +715,16 @@ ul {
/* readme :p */
.readme {
max-width: 700px;
max-width: fit-content;
min-width: 700px;
overflow: hidden;
width: 100%;
background: var(--readme-bg);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
margin-top: 2rem;
margin-top: 1rem;
box-sizing: border-box;
overflow: hidden;
@ -579,7 +802,8 @@ ul {
@media (max-width: 600px) {
.readme {
width: 100%;
max-width: 100%;
min-width: 100%;
padding: 1rem;
margin-top: 1rem;
@ -590,3 +814,184 @@ ul {
font-size: 0.95rem;
}
}
/* reviews */
.reviews {
width: 100%;
max-width: 700px;
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
background-color: var(--card-bg);
padding: 1rem;
border-radius: 10px;
border: 1px solid var(--border-color);
box-sizing: border-box;
}
.reviews h2 {
margin: 0 0 1rem;
font-size: 2rem;
font-weight: 600;
text-align: center;
}
.reviews-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.review {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease;
}
.review:hover {
background-color: var(--card-hover-bg);
}
.review-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.review-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.review-header-inner {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.review-username {
font-weight: 600;
color: var(--text-color);
}
.review-timestamp {
font-size: 0.8rem;
color: var(--text-muted);
}
.review-content {
color: var(--text-secondary);
font-size: 0.95rem;
word-break: break-word;
white-space: pre-wrap;
}
.review-badges {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.emoji {
width: 20px;
height: 20px;
vertical-align: middle;
margin: 0 2px;
display: inline-block;
transition: transform 0.3s ease;
}
.emoji:hover {
transform: scale(1.2);
}
.review-content img.emoji {
vertical-align: middle;
}
@media (max-width: 600px) {
.reviews {
max-width: 100%;
padding: 1rem;
border-radius: 0;
border: none;
background-color: transparent;
}
.reviews h2 {
font-size: 1.4rem;
text-align: center;
margin-bottom: 1rem;
}
.reviews-list {
gap: 0.75rem;
}
.review {
flex-direction: column;
align-items: center;
text-align: center;
padding: 1rem;
border-radius: 0;
}
.review-avatar {
width: 64px;
height: 64px;
}
.review-body {
width: 100%;
align-items: center;
}
.review-header {
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.review-username {
font-size: 1rem;
}
.review-timestamp {
font-size: 0.75rem;
}
.review-content {
font-size: 0.9rem;
}
.review-badges {
justify-content: center;
}
.emoji {
width: 16px;
height: 16px;
}
}

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,5 +1,21 @@
const head = document.querySelector("head");
const userId = head?.dataset.userId;
const activityProgressMap = new Map();
const reviewURL = head?.dataset.reviewDb;
let instanceUri = head?.dataset.instanceUri;
let badgeURL = head?.dataset.badgeUrl;
let socket;
let badgesLoaded = false;
let readmeLoaded = false;
let cssLoaded = false;
const reviewsPerPage = 50;
let currentReviewOffset = 0;
let hasMoreReviews = true;
let isLoadingReviews = false;
function formatTime(ms) {
const totalSecs = Math.floor(ms / 1000);
const hours = Math.floor(totalSecs / 3600);
@ -78,56 +94,14 @@ function updateElapsedAndProgress() {
}
}
updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000);
function loadEffectScript(effect) {
const existing = document.querySelector(`script[data-effect="${effect}"]`);
if (existing) return;
const head = document.querySelector("head");
const userId = head?.dataset.userId;
let instanceUri = head?.dataset.instanceUri;
if (userId && instanceUri) {
if (!instanceUri.startsWith("http")) {
instanceUri = `https://${instanceUri}`;
}
const wsUri = instanceUri
.replace(/^http:/, "ws:")
.replace(/^https:/, "wss:")
.replace(/\/$/, "");
const socket = new WebSocket(`${wsUri}/socket`);
let heartbeatInterval = null;
socket.addEventListener("open", () => {});
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);
});
const script = document.createElement("script");
script.src = `/public/js/${effect}.js`;
script.dataset.effect = effect;
document.head.appendChild(script);
}
function resolveActivityImage(img, applicationId) {
@ -146,9 +120,116 @@ function resolveActivityImage(img, applicationId) {
return `https://i.scdn.co/image/${img.split(":")[1]}`;
}
if (img.startsWith("twitch:")) {
const username = img.split(":")[1];
return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`;
}
return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`;
}
async function populateReviews(userId) {
if (!reviewURL || !userId || isLoadingReviews || !hasMoreReviews) return;
const reviewSection = document.querySelector(".reviews");
const reviewList = reviewSection?.querySelector(".reviews-list");
if (!reviewList) return;
isLoadingReviews = true;
try {
const url = `${reviewURL}/users/${userId}/reviews?flags=2&offset=${currentReviewOffset}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success || !Array.isArray(data.reviews)) {
if (currentReviewOffset === 0) reviewSection.classList.add("hidden");
isLoadingReviews = false;
return;
}
const reviewsHTML = data.reviews
.map((review) => {
const sender = review.sender;
const username = sender.username;
const avatar = sender.profilePhoto;
let comment = review.comment;
comment = comment.replace(
/<(a?):\w+:(\d+)>/g,
(_, animated, id) =>
`<img src="https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "webp"}" class="emoji" alt="emoji" />`,
);
const timestamp = review.timestamp
? new Date(review.timestamp * 1000).toLocaleString(undefined, {
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "N/A";
const badges = (sender.badges || [])
.map(
(b) =>
`<img src="${b.icon}" class="badge" title="${b.description}" alt="${b.name}" />`,
)
.join("");
return `
<li class="review">
<img class="review-avatar" src="${avatar}" alt="${username}'s avatar"/>
<div class="review-body">
<div class="review-header">
<div class="review-header-inner">
<span class="review-username">${username}</span>
<span class="review-badges">${badges}</span>
</div>
<span class="review-timestamp">${timestamp}</span>
</div>
<div class="review-content">${comment}</div>
</div>
</li>
`;
})
.join("");
if (currentReviewOffset === 0) reviewList.innerHTML = reviewsHTML;
else reviewList.insertAdjacentHTML("beforeend", reviewsHTML);
reviewSection.classList.remove("hidden");
hasMoreReviews = data.hasNextPage;
isLoadingReviews = false;
} catch (err) {
console.error("Failed to fetch reviews", err);
isLoadingReviews = false;
}
}
function setupReviewScrollObserver(userId) {
const sentinel = document.createElement("div");
sentinel.className = "review-scroll-sentinel";
document.querySelector(".reviews").appendChild(sentinel);
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMoreReviews && !isLoadingReviews) {
currentReviewOffset += reviewsPerPage;
populateReviews(userId);
}
},
{
rootMargin: "200px",
threshold: 0,
},
);
observer.observe(sentinel);
}
function buildActivityHTML(activity) {
const start = activity.timestamps?.start;
const end = activity.timestamps?.end;
@ -177,18 +258,15 @@ function buildActivityHTML(activity) {
const activityTypeMap = {
0: "Playing",
1: "Streaming",
2: "Listening",
2: "Listening to",
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 activityType = activityTypeMap[activity.type]
? `${activityTypeMap[activity.type]}${activity.type === 2 ? ` ${activity.name}` : ""}`
: "Playing";
const activityTimestamp =
start && progress === null
@ -201,26 +279,36 @@ function buildActivityHTML(activity) {
</div>`
: "";
const activityButtons =
activity.buttons && activity.buttons.length > 0
? `<div class="activity-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)
.join("")}
</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 =
progress !== null
@ -239,12 +327,15 @@ function buildActivityHTML(activity) {
const secondaryLine = isMusic ? activity.state : activity.details;
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
const activityArt = art
? `<div class="activity-image-wrapper">
<img class="activity-image" src="${art}" alt="Art" ${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}>
${smallArt ? `<img class="activity-image-small" src="${smallArt}" alt="Small Art" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>` : ""}
</div>`
: "";
const activityArt = `<div class="activity-image-wrapper ${art ?? "no-asset"}">
<img
class="activity-image${!art ? " no-asset" : ""}"
src="${art ?? ""}"
data-name="${activity.name}"
${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}
/>
${`<img class="activity-image-small ${smallArt ?? "no-asset"}" src="${smallArt ?? ""}" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>`}
</div>`;
return `
<li class="activity">
@ -276,13 +367,231 @@ function buildActivityHTML(activity) {
`;
}
function updatePresence(data) {
const avatarWrapper = document.querySelector(".avatar-wrapper");
const statusIndicator = avatarWrapper?.querySelector(".status-indicator");
const mobileIcon = avatarWrapper?.querySelector(".platform-icon.mobile-only");
async function loadBadges(userId, options = {}) {
const {
services = [],
seperated = false,
cache = true,
targetId = "badges",
serviceOrder = [],
} = options;
const userInfo = document.querySelector(".user-info");
const customStatus = userInfo?.querySelector(".custom-status");
const params = new URLSearchParams();
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) {
if (readmeLoaded) return;
const readmeSection = document.querySelector(".readme");
const kv = data.kv || {};
if (readmeSection && kv.readme) {
const url = 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");
readmeLoaded = true;
} catch (err) {
console.error("Failed to load README", err);
readmeSection.classList.add("hidden");
}
} else if (readmeSection) {
readmeSection.classList.add("hidden");
}
}
async function updatePresence(initialData) {
if (
!initialData ||
typeof initialData !== "object" ||
initialData.success === false ||
initialData.error
) {
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.innerHTML = `
<div class="error-message">
<p>${initialData?.error?.message || "Failed to load presence data."}</p>
</div>
`;
loadingOverlay.style.opacity = "1";
}
return;
}
const data =
initialData?.d && Object.keys(initialData.d).length > 0
? initialData.d
: initialData;
const kv = data.kv || {};
if (kv.optout === "true") {
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.innerHTML = `
<div class="error-message">
<p>This user has opted out of sharing their presence.</p>
</div>
`;
loadingOverlay.style.opacity = "1";
}
return;
}
const cssLink = kv.css;
if (cssLink && !cssLoaded) {
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);
cssLoaded = true;
} catch (err) {
console.error("Failed to load CSS", err);
}
}
if (!badgesLoaded && data?.kv && 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 decorationImg = avatarWrapper?.querySelector(".decoration");
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 (
decorationImg &&
data.discord_user?.avatar_decoration_data &&
data.discord_user.avatar_decoration_data.asset
) {
const newDecorationUrl = `https://cdn.discordapp.com/avatar-decoration-presets/${data.discord_user.avatar_decoration_data.asset}`;
decorationImg.src = newDecorationUrl;
decorationImg.classList.remove("hidden");
} else if (decorationImg) {
decorationImg.src = "";
decorationImg.classList.add("hidden");
}
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);
if (kv.reviews !== "false") {
populateReviews(userId);
setupReviewScrollObserver(userId);
}
const platform = {
mobile: data.active_on_discord_mobile,
@ -297,36 +606,59 @@ function updatePresence(data) {
status = data.discord_status;
}
if (statusIndicator) {
statusIndicator.className = `status-indicator ${status}`;
for (const el of avatarWrapper.querySelectorAll(".platform-icon")) {
const platformType = ["mobile-only", "desktop-only", "web-only"].find(
(type) => el.classList.contains(type),
);
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 && !mobileIcon) {
avatarWrapper.innerHTML += `
<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>
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) {
mobileIcon.remove();
avatarWrapper.innerHTML += `<div class="status-indicator ${status}"></div>`;
avatarWrapper.appendChild(mobileIcon);
}
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);
if (customStatus && custom) {
let emojiHTML = "";
const emoji = custom.emoji;
if (emoji?.id) {
const emojiUrl = `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`;
emojiHTML = `<img src="${emojiUrl}" alt="${emoji.name}" class="custom-emoji">`;
} else if (emoji?.name) {
emojiHTML = `${emoji.name} `;
}
customStatus.innerHTML = `
${emojiHTML}
${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
`;
}
updateCustomStatus(custom);
populateReadme(data);
const filtered = data.activities
?.filter((a) => a.type !== 4)
@ -338,7 +670,7 @@ function updatePresence(data) {
});
const activityList = document.querySelector(".activities");
const activitiesTitle = document.querySelector(".activity-header");
const activitiesTitle = document.querySelector(".activity-block-header");
if (activityList && activitiesTitle) {
if (filtered?.length) {
@ -349,5 +681,175 @@ function updatePresence(data) {
activitiesTitle.classList.add("hidden");
}
updateElapsedAndProgress();
getAllNoAsset();
}
if (kv.snow === "true") loadEffectScript("snow");
if (kv.rain === "true") loadEffectScript("rain");
if (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.error || payload.success === false) {
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.innerHTML = `
<div class="error-message">
<p>${payload.error?.message || "An unknown error occurred."}</p>
</div>
`;
loadingOverlay.style.opacity = "1";
}
return;
}
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);
requestAnimationFrame(updateElapsedAndProgress);
}
});
socket.addEventListener("close", () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
});
}
updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000);

View file

@ -25,24 +25,29 @@ const getRaindropColor = () => {
const createRaindrop = () => {
if (raindrops.length >= maxRaindrops) {
const oldestRaindrop = raindrops.shift();
rainContainer.removeChild(oldestRaindrop);
const oldest = raindrops.shift();
rainContainer.removeChild(oldest);
}
const raindrop = document.createElement("div");
raindrop.classList.add("raindrop");
raindrop.style.position = "absolute";
const height = Math.random() * 10 + 10;
raindrop.style.width = "2px";
raindrop.style.height = `${Math.random() * 10 + 10}px`;
raindrop.style.height = `${height}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.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.speed = Math.random() * 6 + 4;
raindrop.directionX = (Math.random() - 0.5) * 0.2;
raindrop.directionY = Math.random() * 0.5 + 0.8;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
raindrops.push(raindrop);
rainContainer.appendChild(raindrop);
};
@ -51,23 +56,29 @@ setInterval(createRaindrop, 50);
function updateRaindrops() {
raindrops.forEach((raindrop, index) => {
const rect = raindrop.getBoundingClientRect();
const height = Number.parseFloat(raindrop.style.height);
raindrop.style.left = `${rect.left + raindrop.directionX * raindrop.speed}px`;
raindrop.style.top = `${rect.top + raindrop.directionY * raindrop.speed}px`;
raindrop.x += raindrop.directionX * raindrop.speed;
raindrop.y += raindrop.directionY * raindrop.speed;
if (rect.top + rect.height >= window.innerHeight) {
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
if (raindrop.y > window.innerHeight) {
rainContainer.removeChild(raindrop);
raindrops.splice(index, 1);
return;
}
if (
rect.left > window.innerWidth ||
rect.top > window.innerHeight ||
rect.left < 0
raindrop.x > window.innerWidth ||
raindrop.y > window.innerHeight ||
raindrop.x < 0
) {
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
raindrop.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
}
});

View file

@ -1,84 +1,95 @@
document.addEventListener("DOMContentLoaded", () => {
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 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 };
const maxSnowflakes = 60;
const snowflakes = [];
const mouse = { x: -100, y: -100 };
document.addEventListener("mousemove", (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
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);
const createSnowflake = () => {
if (snowflakes.length >= maxSnowflakes) {
const oldestSnowflake = snowflakes.shift();
snowContainer.removeChild(oldestSnowflake);
}
updateSnowflakes();
});
const snowflake = document.createElement("div");
snowflake.classList.add("snowflake");
snowflake.style.position = "absolute";
const size = Math.random() * 3 + 2;
snowflake.style.width = `${size}px`;
snowflake.style.height = `${size}px`;
snowflake.style.background = "white";
snowflake.style.borderRadius = "50%";
snowflake.style.opacity = Math.random();
snowflake.x = Math.random() * window.innerWidth;
snowflake.y = -size;
snowflake.speed = Math.random() * 3 + 2;
snowflake.directionX = (Math.random() - 0.5) * 0.5;
snowflake.directionY = Math.random() * 0.5 + 0.5;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
snowflakes.push(snowflake);
snowContainer.appendChild(snowflake);
};
setInterval(createSnowflake, 80);
function updateSnowflakes() {
snowflakes.forEach((snowflake, index) => {
const size = Number.parseFloat(snowflake.style.width);
const centerX = snowflake.x + size / 2;
const centerY = snowflake.y + size / 2;
const dx = centerX - mouse.x;
const dy = centerY - 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.x += snowflake.directionX * snowflake.speed;
snowflake.y += snowflake.directionY * snowflake.speed;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
if (snowflake.y > window.innerHeight) {
snowContainer.removeChild(snowflake);
snowflakes.splice(index, 1);
return;
}
if (
snowflake.x > window.innerWidth ||
snowflake.y > window.innerHeight ||
snowflake.x < 0
) {
snowflake.x = Math.random() * window.innerWidth;
snowflake.y = -size;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
}
});
requestAnimationFrame(updateSnowflakes);
}
updateSnowflakes();

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

@ -0,0 +1,63 @@
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");
star.className = "star";
const size = Math.random() * 2 + 1;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
star.style.opacity = Math.random();
star.style.top = `${Math.random() * 100}vh`;
star.style.left = `${Math.random() * 100}vw`;
star.style.animationDuration = `${Math.random() * 3 + 2}s`;
container.appendChild(star);
}
function createShootingStar() {
const star = document.createElement("div");
star.className = "shooting-star";
star.x = Math.random() * window.innerWidth * 0.8;
star.y = Math.random() * window.innerHeight * 0.3;
const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8;
const speed = 10;
const totalFrames = 60;
let frame = 0;
const deg = angle * (180 / Math.PI);
star.style.left = `${star.x}px`;
star.style.top = `${star.y}px`;
star.style.transform = `rotate(${deg}deg)`;
container.appendChild(star);
function animate() {
star.x += Math.cos(angle) * speed;
star.y += Math.sin(angle) * speed;
star.style.left = `${star.x}px`;
star.style.top = `${star.y}px`;
star.style.opacity = `${1 - frame / totalFrames}`;
frame++;
if (frame < totalFrames) {
requestAnimationFrame(animate);
} else if (star.parentNode === container) {
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 && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
if (Number.isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", "");
}

View file

@ -1,49 +0,0 @@
import { fetch } from "bun";
import { Vibrant } from "node-vibrant/node";
export async function getImageColors(
url: string,
hex?: boolean,
): Promise<ImageColorResult | null> {
if (!url) return null;
if (typeof url !== "string" || !url.startsWith("http")) return null;
let res: Response;
try {
res = await fetch(url);
} catch {
return null;
}
if (!res.ok) return null;
const type: string | null = res.headers.get("content-type");
if (!type?.startsWith("image/")) return null;
const buffer: Buffer = Buffer.from(await res.arrayBuffer());
const base64: string = buffer.toString("base64");
const colors: Palette = await Vibrant.from(buffer).getPalette();
return {
img: `data:${type};base64,${base64}`,
colors: hex
? {
Muted: rgbToHex(safeRgb(colors.Muted)),
LightVibrant: rgbToHex(safeRgb(colors.LightVibrant)),
Vibrant: rgbToHex(safeRgb(colors.Vibrant)),
LightMuted: rgbToHex(safeRgb(colors.LightMuted)),
DarkVibrant: rgbToHex(safeRgb(colors.DarkVibrant)),
DarkMuted: rgbToHex(safeRgb(colors.DarkMuted)),
}
: colors,
};
}
function safeRgb(swatch: Swatch | null | undefined): number[] {
return Array.isArray(swatch?.rgb) ? (swatch.rgb ?? [0, 0, 0]) : [0, 0, 0];
}
export function rgbToHex(rgb: number[]): string {
return `#${rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
}

View file

@ -1,26 +0,0 @@
import { resolve } from "node:path";
import { renderFile } from "ejs";
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,96 +0,0 @@
import { lanyardConfig } from "@config/environment";
import { fetch } from "bun";
import DOMPurify from "isomorphic-dompurify";
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 = 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;
const html: string | null = await marked.parse(text);
const safe: string | null = DOMPurify.sanitize(html);
return safe;
} catch {
return null;
}
}

View file

@ -1,205 +0,0 @@
import type { Stats } from "node:fs";
import {
type WriteStream,
createWriteStream,
existsSync,
mkdirSync,
statSync,
} from "node:fs";
import { EOL } from "node:os";
import { basename, join } from "node:path";
import { environment } from "@config/environment";
import { timestampToReadable } from "@helpers/char";
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 = 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 = "";
for (let i = 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 = 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 = 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 = 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 = 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 = 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,8 +1,9 @@
import { logger } from "@helpers/logger";
import { serverHandler } from "@/server";
import { verifyRequiredVariables } from "@config/environment";
import { logger } from "@creations.works/logger";
async function main(): Promise<void> {
verifyRequiredVariables();
serverHandler.initialize();
}
@ -10,3 +11,7 @@ main().catch((error: Error) => {
logger.error(["Error initializing the server:", error]);
process.exit(1);
});
if (process.env.IN_PTERODACTYL === "true") {
console.log("Server Started");
}

View file

@ -1,6 +1,11 @@
import { lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
import { resolve } from "node:path";
import {
badgeApi,
lanyardConfig,
plausibleScript,
reviewDb,
} from "@config/environment";
import { file } from "bun";
const routeDef: RouteDef = {
method: "GET",
@ -10,53 +15,36 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> {
const { id } = request.params;
const data: LanyardResponse = await getLanyardData(id);
const instance = lanyardConfig.instance
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
if (!data.success) {
return await renderEjsTemplate("error", {
message: data.error.message,
});
}
const path = resolve("src", "views", "index.html");
const bunFile = file(path);
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("/")) {
instance = instance.slice(0, -1);
}
if (reviewDb.enabled) {
head.setAttribute("data-review-db", reviewDb.url);
}
if (instance.startsWith("http://") || instance.startsWith("https://")) {
instance = instance.slice(instance.indexOf("://") + 3);
}
if (plausibleScript) {
head.append(plausibleScript, { html: true });
}
},
})
.transform(await bunFile.text());
const presence: LanyardData = data.data;
const readme: string | Promise<string> | null = await handleReadMe(presence);
let status: string;
if (presence.activities.some((activity) => activity.type === 1)) {
status = "streaming";
} else {
status = presence.discord_status;
}
const ejsTemplateData: EjsTemplateData = {
title: presence.discord_user.global_name || presence.discord_user.username,
username:
presence.discord_user.global_name || presence.discord_user.username,
status: 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,
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
instance,
readme,
allowSnow: presence.kv.snow || false,
allowRain: presence.kv.rain || false,
};
return await renderEjsTemplate("index", ejsTemplateData);
});
}
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,38 +0,0 @@
import { getImageColors } from "@helpers/colors";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query;
const result: ImageColorResult | null = await getImageColors(url, true);
await getImageColors(url);
if (!result) {
return new Response("Invalid URL", {
status: 400,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"Access-Control-Allow-Origin": "*",
},
});
}
const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(result));
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,71 +1,13 @@
import { getImageColors } from "@/helpers/colors";
import { lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
import { handler as idHandler, routeDef as idRouteDef } from "./[id]";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "text/html",
export const routeDef = {
...idRouteDef,
};
async function handler(): Promise<Response> {
const data: LanyardResponse = await getLanyardData();
if (!data.success) {
return await renderEjsTemplate("error", {
message: data.error.message,
});
}
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);
let status: string;
if (presence.activities.some((activity) => activity.type === 1)) {
status = "streaming";
} else {
status = presence.discord_status;
}
let colors: ImageColorResult | null = null;
if (presence.kv.colors === "true") {
const avatar: string = presence.discord_user.avatar
? `https://cdn.discordapp.com/avatars/${presence.discord_user.id}/${presence.discord_user.avatar}`
: `https://cdn.discordapp.com/embed/avatars/${presence.discord_user.discriminator || 1 % 5}`;
colors = await getImageColors(avatar, true);
}
const ejsTemplateData: EjsTemplateData = {
title: presence.discord_user.global_name || presence.discord_user.username,
username:
presence.discord_user.global_name || presence.discord_user.username,
status: 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,
allowSnow: presence.kv.snow === "true",
allowRain: presence.kv.rain === "true",
colors: colors?.colors ?? {},
};
return await renderEjsTemplate("index", ejsTemplateData);
}
export { handler, routeDef };
export const handler = async (
request: ExtendedRequest,
body: unknown,
server: BunServer,
) => {
return await idHandler(request);
};

View file

@ -1,6 +1,6 @@
import { resolve } from "node:path";
import { environment } from "@config/environment";
import { logger } from "@helpers/logger";
import { environment, robotstxtPath } from "@config/environment";
import { logger } from "@creations.works/logger";
import {
type BunFile,
FileSystemRouter,
@ -44,7 +44,9 @@ class ServerHandler {
];
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();
}
@ -63,10 +65,15 @@ class ServerHandler {
}
}
private async serveStaticFile(pathname: string): Promise<Response> {
try {
let filePath: string;
private async serveStaticFile(
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
try {
if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico");
} else {
@ -79,16 +86,37 @@ class ServerHandler {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = file.type || "application/octet-stream";
return new Response(fileContent, {
response = new Response(fileContent, {
headers: { "Content-Type": contentType },
});
} else {
logger.warn(`File not found: ${filePath}`);
response = new Response("Not Found", { status: 404 });
}
logger.warn(`File not found: ${filePath}`);
return new Response("Not Found", { status: 404 });
} catch (error) {
logger.error([`Error serving static file: ${pathname}`, error as Error]);
return new Response("Internal Server Error", { status: 500 });
response = new Response("Internal Server Error", { status: 500 });
}
this.logRequest(request, response, ip);
return response;
}
private logRequest(
request: ExtendedRequest,
response: Response,
ip: string | undefined,
): void {
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
}
private async handleRequest(
@ -98,14 +126,52 @@ class ServerHandler {
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
extendedRequest.startPerf = performance.now();
const headers = request.headers;
let ip = server.requestIP(request)?.address;
let response: Response;
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip =
headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
"unknown";
}
const pathname: string = new URL(request.url).pathname;
if (pathname === "/robots.txt" && robotstxtPath) {
try {
const file: BunFile = Bun.file(robotstxtPath);
if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = file.type || "text/plain";
response = new Response(fileContent, {
headers: { "Content-Type": contentType },
});
} else {
logger.warn(`File not found: ${robotstxtPath}`);
response = new Response("Not Found", { status: 404 });
}
} catch (error) {
logger.error([
`Error serving robots.txt: ${robotstxtPath}`,
error as Error,
]);
response = new Response("Internal Server Error", { status: 500 });
}
this.logRequest(extendedRequest, response, ip);
return response;
}
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
return await this.serveStaticFile(pathname);
return await this.serveStaticFile(extendedRequest, pathname, ip);
}
const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {};
let response: Response;
if (match) {
const { filePath, params, query } = match;
@ -225,28 +291,6 @@ class ServerHandler {
);
}
const headers: Headers = response.headers;
let ip: string | null = server.requestIP(request)?.address || null;
if (!ip) {
ip =
headers.get("CF-Connecting-IP") ||
headers.get("X-Real-IP") ||
headers.get("X-Forwarded-For") ||
null;
}
logger.custom(
`[${request.method}]`,
`(${response.status})`,
[
request.url,
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
ip || "unknown",
],
"90",
);
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,214 +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>
<% if (allowSnow) { %>
<script src="/public/js/snow.js" defer></script>
<% } %>
<% if(allowRain) { %>
<script src="/public/js/rain.js" defer></script>
<% } %>
<meta name="color-scheme" content="dark">
</head>
<%- include('partial/style.ejs') %>
<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 %>
<% } %>
<% if (activities[0].state) { %>
<span class="custom-status-text"><%= activities[0].state %></span>
<% } %>
</p>
<% } %>
</div>
</div>
</div>
<%
let filtered = activities
.filter(a => a.type !== 4)
.sort((a, b) => {
const priority = { 2: 0, 1: 1, 3: 2 };
const aPriority = priority[a.type] ?? 99;
const bPriority = priority[b.type] ?? 99;
return aPriority - bPriority;
});
%>
<h2 class="activity-header <%= filtered.length === 0 ? 'hidden' : '' %>">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;
let art = null;
let smallArt = null;
function resolveActivityImage(img, applicationId) {
if (!img) return null;
if (img.startsWith("mp:external/")) {
return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`;
}
if (img.includes("/https/")) {
const clean = img.split("/https/")[1];
return clean ? `https://${clean}` : null;
}
if (img.startsWith("spotify:")) {
return `https://i.scdn.co/image/${img.split(":")[1]}`;
}
return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`;
}
if (activity.assets) {
art = resolveActivityImage(activity.assets.large_image, activity.application_id);
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";
%>
<li class="activity">
<div class="activity-wrapper">
<div class="activity-type-wrapper">
<span class="activity-type-label" data-type="<%= activity.type %>"><%= activityType %></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>
<div class="activity-wrapper-inner">
<% if (art) { %>
<div class="activity-image-wrapper">
<img class="activity-image" src="<%= art %>" alt="Art" <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>>
<% if (smallArt) { %>
<img class="activity-image-small" src="<%= smallArt %>" alt="Small Art" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>>
<% } %>
</div>
<% } %>
<div class="activity-content">
<div class="inner-content">
<%
const isMusic = activity.type === 2 || activity.type === 3;
const primaryLine = isMusic ? activity.details : activity.name;
const secondaryLine = isMusic ? activity.state : activity.details;
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
%>
<div class="activity-top">
<div class="activity-header <%= progress !== null ? 'no-timestamp' : '' %>">
<span class="activity-name"><%= primaryLine %></span>
</div>
<% if (secondaryLine) { %>
<div class="activity-detail"><%= secondaryLine %></div>
<% } %>
<% if (tertiaryLine) { %>
<div class="activity-detail"><%= tertiaryLine %></div>
<% } %>
</div>
<div class="activity-bottom">
<% 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>
<% } %>
<% }) %>
</div>
<% } %>
</div>
</div>
</div>
</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) { %>
<section class="readme">
<div class="markdown-body"><%- readme %></div>
</section>
<% } %>
</body>
</html>

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

@ -0,0 +1,74 @@
<!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"/>
<img class="decoration 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-block-header hidden">Activities</h2>
<ul class="activities"></ul>
</section>
<section class="readme hidden" aria-label="Profile README">
<div class="markdown-body"></div>
</section>
</main>
<section class="reviews hidden" aria-label="User Reviews">
<h2>User Reviews</h2>
<ul class="reviews-list">
</ul>
</section>
<script src="/public/js/index.js" type="module"></script>
</body>
</html>

View file

@ -1,31 +0,0 @@
<style>
:root {
--background: <%= colors.DarkVibrant || '#0e0e10' %>;
--readme-bg: <%= colors.DarkMuted || '#1a1a1d' %>;
--card-bg: <%= colors.DarkMuted || '#1e1f22' %>;
--card-hover-bg: <%= colors.Muted || '#2a2a2d' %>;
--border-color: <%= colors.Muted || '#2e2e30' %>;
--text-color: #ffffff;
--text-subtle: #bbb;
--text-secondary: #b5bac1;
--text-muted: #888;
--link-color: <%= colors.Vibrant || '#00b0f4' %>;
--button-bg: <%= colors.Vibrant || '#5865f2' %>;
--button-hover-bg: <%= colors.LightVibrant || '#4752c4' %>;
--button-disabled-bg: #2d2e31;
--progress-bg: <%= colors.DarkVibrant || '#f23f43' %>;
--progress-fill: <%= colors.Vibrant || '#5865f2' %>;
--status-online: #23a55a;
--status-idle: #f0b232;
--status-dnd: #e03e3e;
--status-offline: #747f8d;
--status-streaming: #b700ff;
--blockquote-color: #aaa;
--code-bg: <%= colors.Muted || '#2e2e30' %>;
}
</style>

View file

@ -1,4 +1,4 @@
import { logger } from "@helpers/logger";
import { logger } from "@creations.works/logger";
import type { ServerWebSocket } from "bun";
class WebSocketHandler {

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;
};

8
types/routes.d.ts vendored
View file

@ -3,6 +3,7 @@ type RouteDef = {
accepts: string | null | string[];
returns: string;
needsBody?: "multipart" | "json";
log?: boolean;
};
type RouteModule = {
@ -13,10 +14,3 @@ type RouteModule = {
) => Promise<Response> | Response;
routeDef: RouteDef;
};
type Palette = Awaited<ReturnType<typeof Vibrant.prototype.getPalette>>;
type Swatch = Awaited<ReturnType<typeof Vibrant.prototype.getSwatches>>;
type ImageColorResult = {
img: string;
colors: Palette | Record<string, string>;
};