Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

42 changed files with 1249 additions and 2082 deletions

View file

@ -2,22 +2,6 @@
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,6 +1,4 @@
/node_modules
bun.lock
.env
logs/
.vscode/
robots.txt
logs/

3
.vscode/settings.json vendored Normal file
View file

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

View file

@ -1,38 +0,0 @@
# 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,28 +1,21 @@
BSD 3-Clause License
MIT License
Copyright (c) 2025, creations.works
Copyright (c) 2025 [creations.works]
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
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:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of 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.
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.

114
README.md
View file

@ -1,36 +1,12 @@
# Discord Profile Page
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun.
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS.
# Preview
https://creations.works
## Prerequisite: Lanyard Backend
---
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance for Discord presence data.
## 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
Make sure Lanyard is running and accessible before using this profile page.
---
@ -52,64 +28,35 @@ Copy the example environment file and update it:
cp .env.example .env
```
#### `.env` Variables
#### Required `.env` Variables
| Variable | Description |
|-----------------------|-----------------------------------------------------------------------------|
| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) |
| `PORT` | Port to run the server on (default: `8080`) |
| `REDIS_URL` | Redis connection string |
| `LANYARD_USER_ID` | Your Discord user ID, for the default page |
| `LANYARD_INSTANCE` | Endpoint of the Lanyard instance |
| `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) |
| `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 |
| 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 |
#### Optional Lanyard KV Variables (per-user customization)
#### Optional Lanyard KV Vars (per-user customization)
These can be defined in Lanyard's KV store to customize the page:
These are expected to be defined in Lanyard's KV store:
| 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`) |
| 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`) |
---
### 3. Start the Instance
### 3. Start the App
```bash
bun run start
```
---
## Optional: Analytics with Plausible
You can enable [Plausible Analytics](https://plausible.io) tracking by setting a script snippet in your environment.
### `.env` Variable
| Variable | Description |
|-------------------------|------------------------------------------------------------------------|
| `PLAUSIBLE_SCRIPT_HTML` | Full `<script>` tag(s) to inject into the `<head>` for analytics |
#### Example
```env
PLAUSIBLE_SCRIPT_HTML='<script defer data-domain="example.com" src="https://plausible.example.com/js/script.js"></script><script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>'
```
- The script will only be injected if this variable is set.
- Plausible provides the correct script when you add a domain.
- Be sure to wrap it in single quotes (`'`) so it works in `.env`.
Then open `http://localhost:8080` in your browser.
---
@ -121,23 +68,20 @@ PLAUSIBLE_SCRIPT_HTML='<script defer data-domain="example.com" src="https://plau
docker compose up -d --build
```
Make sure the `.env` file is configured correctly before starting the container.
Make sure your `.env` file is correctly configured before starting.
---
## Routes
## Tech Stack
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.
- Bun Runtime
- EJS Templating
- CSS Styling
- node-vibrant Avatar color extraction
- Biome.js Linting and formatting
---
## License
[BSD 3](LICENSE)
[MIT](/LICENSE)

View file

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

View file

@ -1,29 +0,0 @@
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,69 +1,11 @@
import { resolve } from "node:path";
import { logger } from "@creations.works/logger";
const environment: Environment = {
export 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"),
};
const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
const lanyardConfig: LanyardConfig = {
export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "",
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,
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
};

View file

@ -12,13 +12,16 @@
"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": {
"@creations.works/logger": "^1.0.3",
"marked": "^15.0.7"
"ejs": "^3.1.10",
"isomorphic-dompurify": "^2.23.0",
"marked": "^15.0.7",
"node-vibrant": "^4.0.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

View file

@ -1,36 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

25
public/css/error.css Normal file
View file

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

View file

@ -1,29 +0,0 @@
: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,21 +1,5 @@
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);
@ -94,14 +78,56 @@ function updateElapsedAndProgress() {
}
}
function loadEffectScript(effect) {
const existing = document.querySelector(`script[data-effect="${effect}"]`);
if (existing) return;
updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000);
const script = document.createElement("script");
script.src = `/public/js/${effect}.js`;
script.dataset.effect = effect;
document.head.appendChild(script);
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);
});
}
function resolveActivityImage(img, applicationId) {
@ -120,116 +146,9 @@ 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;
@ -258,15 +177,18 @@ function buildActivityHTML(activity) {
const activityTypeMap = {
0: "Playing",
1: "Streaming",
2: "Listening to",
2: "Listening",
3: "Watching",
4: "Custom Status",
5: "Competing",
};
const activityType = activityTypeMap[activity.type]
? `${activityTypeMap[activity.type]}${activity.type === 2 ? ` ${activity.name}` : ""}`
: "Playing";
const activityType =
activity.name === "Spotify"
? "Listening to Spotify"
: activity.name === "TIDAL"
? "Listening to TIDAL"
: activityTypeMap[activity.type] || "Playing";
const activityTimestamp =
start && progress === null
@ -279,36 +201,26 @@ function buildActivityHTML(activity) {
</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 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 progressBar =
progress !== null
@ -327,15 +239,12 @@ function buildActivityHTML(activity) {
const secondaryLine = isMusic ? activity.state : activity.details;
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
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>`;
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>`
: "";
return `
<li class="activity">
@ -367,231 +276,13 @@ function buildActivityHTML(activity) {
`;
}
async function loadBadges(userId, options = {}) {
const {
services = [],
seperated = false,
cache = true,
targetId = "badges",
serviceOrder = [],
} = options;
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;
}
function updatePresence(data) {
const avatarWrapper = document.querySelector(".avatar-wrapper");
const avatarImg = avatarWrapper?.querySelector(".avatar");
const decorationImg = avatarWrapper?.querySelector(".decoration");
const usernameEl = document.querySelector(".username");
const statusIndicator = avatarWrapper?.querySelector(".status-indicator");
const mobileIcon = avatarWrapper?.querySelector(".platform-icon.mobile-only");
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 userInfo = document.querySelector(".user-info");
const customStatus = userInfo?.querySelector(".custom-status");
const platform = {
mobile: data.active_on_discord_mobile,
@ -606,59 +297,36 @@ async function updatePresence(initialData) {
status = data.discord_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 (statusIndicator) {
statusIndicator.className = `status-indicator ${status}`;
}
if (
platform.mobile &&
!avatarWrapper.querySelector(".platform-icon.mobile-only")
) {
const mobileIcon = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
mobileIcon.setAttribute("class", `platform-icon mobile-only ${status}`);
mobileIcon.setAttribute("viewBox", "0 0 1000 1500");
mobileIcon.setAttribute("fill", "#43a25a");
mobileIcon.setAttribute("aria-label", "Mobile");
mobileIcon.setAttribute("width", "17");
mobileIcon.setAttribute("height", "17");
mobileIcon.innerHTML = `
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/>
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>
`;
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}`;
} else if (!platform.mobile && mobileIcon) {
mobileIcon.remove();
avatarWrapper.innerHTML += `<div class="status-indicator ${status}"></div>`;
}
const custom = data.activities?.find((a) => a.type === 4);
updateCustomStatus(custom);
populateReadme(data);
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>` : ""}
`;
}
const filtered = data.activities
?.filter((a) => a.type !== 4)
@ -670,7 +338,7 @@ async function updatePresence(initialData) {
});
const activityList = document.querySelector(".activities");
const activitiesTitle = document.querySelector(".activity-block-header");
const activitiesTitle = document.querySelector(".activity-header");
if (activityList && activitiesTitle) {
if (filtered?.length) {
@ -681,175 +349,5 @@ async function updatePresence(initialData) {
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,29 +25,24 @@ const getRaindropColor = () => {
const createRaindrop = () => {
if (raindrops.length >= maxRaindrops) {
const oldest = raindrops.shift();
rainContainer.removeChild(oldest);
const oldestRaindrop = raindrops.shift();
rainContainer.removeChild(oldestRaindrop);
}
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 = `${height}px`;
raindrop.style.height = `${Math.random() * 10 + 10}px`;
raindrop.style.background = getRaindropColor();
raindrop.style.borderRadius = "1px";
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
raindrop.style.opacity = Math.random() * 0.5 + 0.3;
raindrop.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);
};
@ -56,29 +51,23 @@ setInterval(createRaindrop, 50);
function updateRaindrops() {
raindrops.forEach((raindrop, index) => {
const height = Number.parseFloat(raindrop.style.height);
const rect = raindrop.getBoundingClientRect();
raindrop.x += raindrop.directionX * raindrop.speed;
raindrop.y += raindrop.directionY * raindrop.speed;
raindrop.style.left = `${rect.left + raindrop.directionX * raindrop.speed}px`;
raindrop.style.top = `${rect.top + raindrop.directionY * raindrop.speed}px`;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
if (raindrop.y > window.innerHeight) {
if (rect.top + rect.height >= window.innerHeight) {
rainContainer.removeChild(raindrop);
raindrops.splice(index, 1);
return;
}
if (
raindrop.x > window.innerWidth ||
raindrop.y > window.innerHeight ||
raindrop.x < 0
rect.left > window.innerWidth ||
rect.top > window.innerHeight ||
rect.left < 0
) {
raindrop.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
}
});

View file

@ -1,95 +1,84 @@
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);
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 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;
});
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";
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`;
}
document.addEventListener("mousemove", (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
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";
snowflake.style.width = `${Math.random() * 3 + 2}px`;
snowflake.style.height = snowflake.style.width;
snowflake.style.background = "white";
snowflake.style.borderRadius = "50%";
snowflake.style.opacity = Math.random();
snowflake.style.left = `${Math.random() * window.innerWidth}px`;
snowflake.style.top = `-${snowflake.style.height}`;
snowflake.speed = Math.random() * 3 + 2;
snowflake.directionX = (Math.random() - 0.5) * 0.5;
snowflake.directionY = Math.random() * 0.5 + 0.5;
snowflakes.push(snowflake);
snowContainer.appendChild(snowflake);
};
setInterval(createSnowflake, 80);
function updateSnowflakes() {
snowflakes.forEach((snowflake, index) => {
const rect = snowflake.getBoundingClientRect();
const dx = rect.left + rect.width / 2 - mouse.x;
const dy = rect.top + rect.height / 2 - mouse.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 30) {
snowflake.directionX += (dx / distance) * 0.02;
snowflake.directionY += (dy / distance) * 0.02;
} else {
snowflake.directionX += (Math.random() - 0.5) * 0.01;
snowflake.directionY += (Math.random() - 0.5) * 0.01;
}
snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`;
snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`;
if (rect.top + rect.height >= window.innerHeight) {
snowContainer.removeChild(snowflake);
snowflakes.splice(index, 1);
}
if (
rect.left > window.innerWidth ||
rect.top > window.innerHeight ||
rect.left < 0
) {
snowflake.style.left = `${Math.random() * window.innerWidth}px`;
snowflake.style.top = `-${snowflake.style.height}`;
}
});
requestAnimationFrame(updateSnowflakes);
}
updateSnowflakes();
});

View file

@ -1,63 +0,0 @@
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);

6
src/helpers/char.ts Normal file
View file

@ -0,0 +1,6 @@
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", "");
}

49
src/helpers/colors.ts Normal file
View file

@ -0,0 +1,49 @@
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("")}`;
}

26
src/helpers/ejs.ts Normal file
View file

@ -0,0 +1,26 @@
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 },
});
}

96
src/helpers/lanyard.ts Normal file
View file

@ -0,0 +1,96 @@
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;
}
}

205
src/helpers/logger.ts Normal file
View file

@ -0,0 +1,205 @@
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,9 +1,8 @@
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();
}
@ -11,7 +10,3 @@ 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,11 +1,6 @@
import { resolve } from "node:path";
import {
badgeApi,
lanyardConfig,
plausibleScript,
reviewDb,
} from "@config/environment";
import { file } from "bun";
import { lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
const routeDef: RouteDef = {
method: "GET",
@ -15,36 +10,53 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> {
const { id } = request.params;
const instance = lanyardConfig.instance
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
const data: LanyardResponse = await getLanyardData(id);
const path = resolve("src", "views", "index.html");
const bunFile = file(path);
if (!data.success) {
return await renderEjsTemplate("error", {
message: data.error.message,
});
}
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 || "");
let instance: string = lanyardConfig.instance;
if (reviewDb.enabled) {
head.setAttribute("data-review-db", reviewDb.url);
}
if (instance.endsWith("/")) {
instance = instance.slice(0, -1);
}
if (plausibleScript) {
head.append(plausibleScript, { html: true });
}
},
})
.transform(await bunFile.text());
if (instance.startsWith("http://") || instance.startsWith("https://")) {
instance = instance.slice(instance.indexOf("://") + 3);
}
return new Response(html, {
headers: {
"Content-Type": "text/html",
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,
},
});
instance,
readme,
allowSnow: presence.kv.snow || false,
allowRain: presence.kv.rain || false,
};
return await renderEjsTemplate("index", ejsTemplateData);
}
export { handler, routeDef };

View file

@ -1,93 +0,0 @@
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 };

38
src/routes/api/colors.ts Normal file
View file

@ -0,0 +1,38 @@
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 };

View file

@ -1,85 +0,0 @@
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 };

View file

@ -1,139 +0,0 @@
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,13 +1,71 @@
import { handler as idHandler, routeDef as idRouteDef } from "./[id]";
import { getImageColors } from "@/helpers/colors";
import { lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard";
export const routeDef = {
...idRouteDef,
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "text/html",
};
export const handler = async (
request: ExtendedRequest,
body: unknown,
server: BunServer,
) => {
return await idHandler(request);
};
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 };

View file

@ -1,6 +1,6 @@
import { resolve } from "node:path";
import { environment, robotstxtPath } from "@config/environment";
import { logger } from "@creations.works/logger";
import { environment } from "@config/environment";
import { logger } from "@helpers/logger";
import {
type BunFile,
FileSystemRouter,
@ -44,9 +44,7 @@ class ServerHandler {
];
logger.info(`Server running at ${accessUrls[0]}`);
logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, {
breakLine: true,
});
logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true);
this.logRoutes();
}
@ -65,15 +63,10 @@ class ServerHandler {
}
}
private async serveStaticFile(
request: ExtendedRequest,
pathname: string,
ip: string,
): Promise<Response> {
let filePath: string;
let response: Response;
private async serveStaticFile(pathname: string): Promise<Response> {
try {
let filePath: string;
if (pathname === "/favicon.ico") {
filePath = resolve("public", "assets", "favicon.ico");
} else {
@ -86,37 +79,16 @@ class ServerHandler {
const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = file.type || "application/octet-stream";
response = new Response(fileContent, {
return 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]);
response = new Response("Internal Server Error", { status: 500 });
return 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(
@ -126,52 +98,14 @@ 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(extendedRequest, pathname, ip);
return await this.serveStaticFile(pathname);
}
const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {};
let response: Response;
if (match) {
const { filePath, params, query } = match;
@ -291,6 +225,28 @@ 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;
}
}

17
src/views/error.ejs Normal file
View file

@ -0,0 +1,17 @@
<!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>

214
src/views/index.ejs Normal file
View file

@ -0,0 +1,214 @@
<!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>

View file

@ -1,74 +0,0 @@
<!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

@ -0,0 +1,31 @@
<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 "@creations.works/logger";
import { logger } from "@helpers/logger";
import type { ServerWebSocket } from "bun";
class WebSocketHandler {

3
types/ejs.d.ts vendored Normal file
View file

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

72
types/lanyard.d.ts vendored Normal file
View file

@ -0,0 +1,72 @@
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 Normal file
View file

@ -0,0 +1,9 @@
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,7 +3,6 @@ type RouteDef = {
accepts: string | null | string[];
returns: string;
needsBody?: "multipart" | "json";
log?: boolean;
};
type RouteModule = {
@ -14,3 +13,10 @@ 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>;
};