Compare commits

..

19 commits
main ... main

Author SHA1 Message Date
8b7bedbf0b
fix mobile icon 2025-04-19 14:06:37 -04:00
bf66b301ae
add badges and fix clan tags, and readme issue 2025-04-19 13:37:37 -04:00
7816210a2c
add clan badges 2025-04-18 04:31:25 -04:00
b109f67125
fix ip log issue, make changes to embed 2025-04-17 18:57:45 -04:00
23f37beef3
update to allow html readme, fx id page for colors 2025-04-17 18:26:59 -04:00
7f9f166f8a
fix readme 2025-04-11 19:50:57 -04:00
59b354e43c
add a readme and license 2025-04-11 19:31:10 -04:00
59d3a6b3e2
update package 2025-04-10 19:26:24 -04:00
0d5fbe76b7
forgot comment 2025-04-10 07:09:27 -04:00
ff0ece9626
add option to use vibrant colors from avatar needs moving around 2025-04-10 07:09:10 -04:00
30e9057ba8
update biome.json and add workflow 2025-04-10 06:29:21 -04:00
f4aeb7aafb
this is why i need a dev branch 2025-04-09 18:41:16 -04:00
5e94af5980
not me forgetting console logs 2025-04-09 18:38:24 -04:00
c54d959e7e
fix issue with rain and snow, fix ejs formatting 2025-04-09 18:35:08 -04:00
78c2eb4545
add rain and snow kv options fix issue with activity header not showing when no initial activity 2025-04-09 18:23:52 -04:00
66744ddd10
move all colors to :root, add activity small image and hover text, add support for streaming indicator, creations/profilePage#2 2025-04-07 18:58:54 -04:00
d91e832eab
move to biomejs 2025-04-07 04:36:07 -04:00
7d78a74a25
move to discord proxy for images, add lanyard hb, creations/profilePage#6 2025-04-06 21:41:53 -04:00
c79ee2b203
fix xss issue aka: creations/profilePage#3, update depends change how activities display, remove readme title, 2025-04-06 20:59:38 -04:00
29 changed files with 1272 additions and 569 deletions

View file

@ -5,3 +5,6 @@ PORT=8080
# this is only the default value if non is give in /id # this is only the default value if non is give in /id
LANYARD_USER_ID=id-here LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081

View file

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

21
LICENSE Normal file
View file

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

View file

@ -1,3 +1,88 @@
# Cool little discord profile page # Discord Profile Page
E A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS.
## Prerequisite: Lanyard Backend
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance for Discord presence data.
Make sure Lanyard is running and accessible before using this profile page.
---
## Getting Started
### 1. Clone & Install
```bash
git clone https://git.creations.works/creations/profilePage.git
cd profilePage
bun install
```
### 2. Configure Environment
Copy the example environment file and update it:
```bash
cp .env.example .env
```
#### 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`) |
| `LANYARD_USER_ID` | Your Discord user ID |
| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL |
| `BADGE_API_URL` | Uses the [badge api](https://git.creations.works/creations/badgeAPI) only required if you want to use badges
#### Optional Lanyard KV Vars (per-user customization)
These are expected to be defined in Lanyard's KV store:
| 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 App
```bash
bun run start
```
Then open `http://localhost:8080` in your browser.
---
## Docker Support
### Build & Start with Docker Compose
```bash
docker compose up -d --build
```
Make sure your `.env` file is correctly configured before starting.
---
## Tech Stack
- Bun Runtime
- EJS Templating
- CSS Styling
- node-vibrant Avatar color extraction
- Biome.js Linting and formatting
---
## License
[MIT](/LICENSE)

41
biome.json Normal file
View file

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

View file

@ -1,12 +1,13 @@
export const environment: Environment = { export const environment: Environment = {
port: parseInt(process.env.PORT || "8080", 10), port: Number.parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0", host: process.env.HOST || "0.0.0.0",
development: development:
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
process.argv.includes("--dev"),
}; };
export const lanyardConfig: LanyardConfig = { export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "", userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
}; };
export const badgeApi: string | null = process.env.BADGE_API_URL || null;

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
body { body {
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
background-color: #0e0e10; background-color: var(--background);
color: #ffffff; color: var(--text-color);
margin: 0; margin: 0;
padding: 2rem; padding: 2rem;
display: flex; display: flex;
@ -9,6 +9,30 @@ body {
align-items: center; align-items: center;
} }
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.raindrop {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.hidden {
display: none !important;
}
.activity-header.hidden {
display: none;
}
.user-card { .user-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -21,7 +45,10 @@ body {
.avatar-status-wrapper { .avatar-status-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 2rem;
width: fit-content;
max-width: 700px;
} }
.avatar-wrapper { .avatar-wrapper {
@ -36,6 +63,28 @@ body {
border-radius: 50%; border-radius: 50%;
} }
.badges {
max-width: 700px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.badge {
width: 26px;
height: 26px;
border-radius: 50%;
}
.decoration { .decoration {
position: absolute; position: absolute;
top: -18px; top: -18px;
@ -52,35 +101,42 @@ body {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
border: 4px solid #0e0e10; border: 4px solid var(--background);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.status-indicator.online { .status-indicator.online {
background-color: #23a55a; background-color: var(--status-online);
} }
.status-indicator.idle { .status-indicator.idle {
background-color: #f0b232; background-color: var(--status-idle);
} }
.status-indicator.dnd { .status-indicator.dnd {
background-color: #f23f43; background-color: var(--status-dnd);
} }
.status-indicator.offline { .status-indicator.offline {
background-color: #747f8d; background-color: var(--status-offline);
}
.status-indicator.streaming {
background-color: var(--status-streaming);
} }
.platform-icon.mobile-only { .platform-icon.mobile-only {
position: absolute; position: absolute;
bottom: 4px; bottom: 0;
right: 4px; right: 4px;
width: 30px; width: 30px;
height: 30px; height: 30px;
pointer-events: none; pointer-events: none;
background-color: var(--background);
padding: 0.3rem 0.1rem;
border-radius: 8px;
} }
.user-info { .user-info {
@ -88,15 +144,61 @@ body {
flex-direction: column; flex-direction: column;
} }
.user-info-inner {
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
gap: .5rem;
}
.user-info-inner h1 {
font-size: 2rem;
margin: 0;
}
.clan-badge {
width: 50px;
height: 20px;
border-radius: 8px;
background-color: var(--card-bg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: .4rem 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: .9rem;
color: var(--text-color);
margin: 0;
font-weight: 600;
}
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
margin: 0; margin: 0;
color: #00b0f4; color: var(--link-color);
} }
.custom-status { .custom-status {
font-size: 1.2rem; font-size: 1.2rem;
color: #bbb; color: var(--text-subtle);
margin-top: 0.25rem; margin-top: 0.25rem;
word-break: break-word; word-break: break-word;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@ -107,7 +209,6 @@ h1 {
flex-wrap: wrap; flex-wrap: wrap;
} }
.custom-status .custom-emoji { .custom-status .custom-emoji {
width: 20px; width: 20px;
height: 20px; height: 20px;
@ -142,19 +243,50 @@ ul {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
background: #1a1a1d; background-color: var(--card-bg);
padding: 1rem; padding: 0.75rem 1rem;
border-radius: 6px; border-radius: 10px;
box-shadow: 0 0 0 1px #2e2e30; border: 1px solid var(--border-color);
transition: background 0.2s ease;
align-items: flex-start;
} }
.activity:hover { .activity:hover {
background: #2a2a2d; background: var(--card-hover-bg);
} }
.activity-art { .activity-wrapper {
display: flex;
flex-direction: column;
width: 100%;
}
.activity-wrapper-inner {
display: flex;
flex-direction: row;
gap: 1rem;
}
.activity-image-wrapper {
position: relative;
width: 80px;
height: 80px;
}
.activity-image-small {
width: 25px;
height: 25px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border-color: var(--card-bg);
border-width: 2px;
border-style: solid;
position: absolute;
bottom: -6px;
right: -10px;
}
.activity-image {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 6px; border-radius: 6px;
@ -165,7 +297,15 @@ ul {
.activity-content { .activity-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
flex: 1; flex: 1;
gap: 0.5rem;
position: relative;
}
.activity-top {
display: flex;
flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
@ -175,64 +315,91 @@ ul {
align-items: flex-start; align-items: flex-start;
} }
.activity-bottom {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.activity-name { .activity-name {
font-weight: bold; font-weight: 600;
font-size: 1.1rem; font-size: 1rem;
color: #ffffff; color: var(--text-color);
} }
.activity-detail { .activity-detail {
font-size: 0.95rem; font-size: 0.875rem;
color: #ccc; color: var(--text-secondary);
} }
.activity-timestamp { .activity-timestamp {
font-size: 0.8rem; font-size: 0.75rem;
color: #777; color: var(--text-secondary);
text-align: right; text-align: right;
margin-left: auto;
white-space: nowrap;
} }
.progress-bar { .progress-bar {
height: 6px; height: 4px;
background-color: #333; background-color: var(--border-color);
border-radius: 3px; border-radius: 2px;
margin-top: 1rem;
overflow: hidden; overflow: hidden;
width: 100%;
margin-top: 0.5rem;
} }
.progress-fill { .progress-fill {
background-color: var(--progress-fill);
transition: width 0.4s ease;
height: 100%; height: 100%;
background-color: #00b0f4; }
transition: width 0.5s ease;
.progress-bar,
.progress-time-labels {
width: 100%;
} }
.progress-time-labels { .progress-time-labels {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.75rem; font-size: 0.75rem;
color: #888; color: var(--text-muted);
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.activity-type-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.activity-type-label {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
color: var(--blockquote-color);
margin-bottom: 0.50rem;
display: block;
}
.activity-header.no-timestamp { .activity-header.no-timestamp {
justify-content: flex-start; justify-content: flex-start;
} }
.progress-time-labels.paused .progress-current::after { .progress-time-labels.paused .progress-current::after {
content: " ⏸"; content: " ⏸";
color: #f0b232; color: var(--status-idle);
} }
.activity-buttons { .activity-buttons {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.75rem; margin-top: 0.75rem;
justify-content: flex-end;
} }
.activity-button { .activity-button {
background-color: #5865f2; background-color: var(--progress-fill);
color: white; color: white;
border: none; border: none;
border-radius: 3px; border-radius: 3px;
@ -245,14 +412,13 @@ ul {
} }
.activity-button:hover { .activity-button:hover {
background-color: #4752c4; background-color: var(--button-hover-bg);
text-decoration: none; text-decoration: none;
} }
.activity-button.disabled { .activity-button:disabled {
background-color: #4e5058; background-color: var(--button-disabled-bg);
cursor: default; cursor: not-allowed;
pointer-events: none;
opacity: 0.8; opacity: 0.8;
} }
@ -272,6 +438,14 @@ ul {
align-items: center; align-items: center;
} }
.badges {
max-width: 100%;
border-radius: 0;
border: none;
background-color: transparent;
margin-top: 0;
}
.avatar-status-wrapper { .avatar-status-wrapper {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -280,6 +454,13 @@ ul {
width: 100%; width: 100%;
} }
.activity-image-wrapper {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
.avatar-wrapper { .avatar-wrapper {
width: 96px; width: 96px;
height: 96px; height: 96px;
@ -334,21 +515,31 @@ ul {
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
border-radius:0; border-radius: 0;
} }
.activity-art { .activity-image {
width: 100%; width: 100%;
max-width: 300px; max-width: 100%;
height: auto; height: auto;
border-radius: 8px; border-radius: 8px;
} }
.activity-image-small {
width: 40px;
height: 40px;
}
.activity-content { .activity-content {
width: 100%; width: 100%;
align-items: center; align-items: center;
} }
.activity-wrapper-inner {
flex-direction: column;
align-items: center;
}
.activity-header { .activity-header {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -385,12 +576,16 @@ ul {
/* readme :p */ /* readme :p */
.readme { .readme {
max-width: 600px; max-width: fit-content;
min-width: 700px;
overflow: hidden;
width: 100%; width: 100%;
background: #1a1a1d; background: var(--readme-bg);
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 0 1px #2e2e30; border: 1px solid var(--border-color);
margin-top: 1rem;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
@ -399,13 +594,13 @@ ul {
.readme h2 { .readme h2 {
margin-top: 0; margin-top: 0;
color: #00b0f4; color: var(--link-color);
} }
.markdown-body { .markdown-body {
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
color: #ddd; color: var(--text-color);
} }
.markdown-body h1, .markdown-body h1,
@ -414,7 +609,7 @@ ul {
.markdown-body h4, .markdown-body h4,
.markdown-body h5, .markdown-body h5,
.markdown-body h6 { .markdown-body h6 {
color: #ffffff; color: var(--text-color);
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -424,7 +619,7 @@ ul {
} }
.markdown-body a { .markdown-body a {
color: #00b0f4; color: var(--link-color);
text-decoration: none; text-decoration: none;
} }
@ -433,7 +628,7 @@ ul {
} }
.markdown-body code { .markdown-body code {
background: #2e2e30; background: var(--border-color);
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
@ -441,7 +636,7 @@ ul {
} }
.markdown-body pre { .markdown-body pre {
background: #2e2e30; background: var(--border-color);
padding: 1rem; padding: 1rem;
border-radius: 6px; border-radius: 6px;
overflow-x: auto; overflow-x: auto;
@ -456,9 +651,9 @@ ul {
} }
.markdown-body blockquote { .markdown-body blockquote {
border-left: 4px solid #00b0f4; border-left: 4px solid var(--link-color);
padding-left: 1rem; padding-left: 1rem;
color: #aaa; color: var(--blockquote-color);
margin: 1rem 0; margin: 1rem 0;
} }
@ -468,7 +663,8 @@ ul {
@media (max-width: 600px) { @media (max-width: 600px) {
.readme { .readme {
width: 100%; max-width: 100%;
min-width: 100%;
padding: 1rem; padding: 1rem;
margin-top: 1rem; margin-top: 1rem;

View file

@ -1,36 +1,42 @@
/* eslint-disable indent */
const activityProgressMap = new Map(); const activityProgressMap = new Map();
function formatTime(ms) { function formatTime(ms) {
const totalSecs = Math.floor(ms / 1000); const totalSecs = Math.floor(ms / 1000);
const mins = Math.floor(totalSecs / 60); const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60; const secs = totalSecs % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
return `${String(hours).padStart(1, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
function formatVerbose(ms) {
const totalSecs = Math.floor(ms / 1000);
const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
return `${hours}h ${mins}m ${secs}s`;
} }
function updateElapsedAndProgress() { function updateElapsedAndProgress() {
const now = Date.now(); const now = Date.now();
document.querySelectorAll(".activity-timestamp").forEach((el) => { for (const el of document.querySelectorAll(".activity-timestamp")) {
const start = Number(el.dataset.start); const start = Number(el.dataset.start);
if (!start) return; if (!start) continue;
const elapsed = now - start; const elapsed = now - start;
const mins = Math.floor(elapsed / 60000);
const secs = Math.floor((elapsed % 60000) / 1000);
const display = el.querySelector(".elapsed"); const display = el.querySelector(".elapsed");
if (display) if (display) display.textContent = `(${formatVerbose(elapsed)} ago)`;
display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`; }
});
document.querySelectorAll(".progress-bar").forEach((bar) => { for (const bar of document.querySelectorAll(".progress-bar")) {
const start = Number(bar.dataset.start); const start = Number(bar.dataset.start);
const end = Number(bar.dataset.end); const end = Number(bar.dataset.end);
if (!start || !end || end <= start) return; if (!start || !end || end <= start) continue;
const duration = end - start; const duration = end - start;
const elapsed = now - start; const elapsed = Math.min(now - start, duration);
const progress = Math.min( const progress = Math.min(
100, 100,
Math.max(0, Math.floor((elapsed / duration) * 100)), Math.max(0, Math.floor((elapsed / duration) * 100)),
@ -38,14 +44,15 @@ function updateElapsedAndProgress() {
const fill = bar.querySelector(".progress-fill"); const fill = bar.querySelector(".progress-fill");
if (fill) fill.style.width = `${progress}%`; if (fill) fill.style.width = `${progress}%`;
}); }
document.querySelectorAll(".progress-time-labels").forEach((label) => { for (const label of document.querySelectorAll(".progress-time-labels")) {
const start = Number(label.dataset.start); const start = Number(label.dataset.start);
const end = Number(label.dataset.end); const end = Number(label.dataset.end);
if (!start || !end || end <= start) return; if (!start || !end || end <= start) continue;
const current = Math.max(0, now - start); const isPaused = now > end;
const current = isPaused ? end - start : Math.max(0, now - start);
const total = end - start; const total = end - start;
const currentEl = label.querySelector(".progress-current"); const currentEl = label.querySelector(".progress-current");
@ -54,7 +61,7 @@ function updateElapsedAndProgress() {
const id = `${start}-${end}`; const id = `${start}-${end}`;
const last = activityProgressMap.get(id); const last = activityProgressMap.get(id);
if (last !== undefined && last === current) { if (isPaused || (last !== undefined && last === current)) {
label.classList.add("paused"); label.classList.add("paused");
} else { } else {
label.classList.remove("paused"); label.classList.remove("paused");
@ -62,17 +69,22 @@ function updateElapsedAndProgress() {
activityProgressMap.set(id, current); activityProgressMap.set(id, current);
if (currentEl) currentEl.textContent = formatTime(current); if (currentEl) {
currentEl.textContent = isPaused
? `Paused at ${formatTime(current)}`
: formatTime(current);
}
if (totalEl) totalEl.textContent = formatTime(total); if (totalEl) totalEl.textContent = formatTime(total);
}); }
} }
updateElapsedAndProgress(); updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000); setInterval(updateElapsedAndProgress, 1000);
const head = document.querySelector("head"); const head = document.querySelector("head");
let userId = head?.dataset.userId; const userId = head?.dataset.userId;
let instanceUri = head?.dataset.instanceUri; let instanceUri = head?.dataset.instanceUri;
let badgeURL = head?.dataset.badgeUrl;
if (userId && instanceUri) { if (userId && instanceUri) {
if (!instanceUri.startsWith("http")) { if (!instanceUri.startsWith("http")) {
@ -86,7 +98,18 @@ if (userId && instanceUri) {
const socket = new WebSocket(`${wsUri}/socket`); const socket = new WebSocket(`${wsUri}/socket`);
socket.addEventListener("open", () => { 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( socket.send(
JSON.stringify({ JSON.stringify({
op: 2, op: 2,
@ -95,16 +118,36 @@ if (userId && instanceUri) {
}, },
}), }),
); );
}); }
socket.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") {
updatePresence(payload.d); updatePresence(payload.d);
updateElapsedAndProgress(); requestAnimationFrame(() => updateElapsedAndProgress());
} }
}); });
socket.addEventListener("close", () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
});
}
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`;
} }
function buildActivityHTML(activity) { function buildActivityHTML(activity) {
@ -118,87 +161,199 @@ function buildActivityHTML(activity) {
? Math.min(100, Math.floor((elapsed / total) * 100)) ? Math.min(100, Math.floor((elapsed / total) * 100))
: null; : null;
const img = activity.assets?.large_image;
let art = null; let art = null;
if (img?.includes("https")) { let smallArt = null;
const clean = img.split("/https/")[1];
if (clean) art = `https://${clean}`; if (activity.assets) {
} else if (img?.startsWith("spotify:")) { art = resolveActivityImage(
art = `https://i.scdn.co/image/${img.split(":")[1]}`; activity.assets.large_image,
} else if (img) { activity.application_id,
art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; );
smallArt = resolveActivityImage(
activity.assets.small_image,
activity.application_id,
);
} }
const activityTypeMap = {
0: "Playing",
1: "Streaming",
2: "Listening",
3: "Watching",
4: "Custom Status",
5: "Competing",
};
const activityType =
activity.name === "Spotify"
? "Listening to Spotify"
: activity.name === "TIDAL"
? "Listening to TIDAL"
: activityTypeMap[activity.type] || "Playing";
const activityTimestamp = const activityTimestamp =
!total && start start && progress === null
? ` ? `<div class="activity-timestamp" data-start="${start}">
<div class="activity-timestamp" data-start="${start}"> <span>Since: ${new Date(start).toLocaleTimeString("en-GB", {
<span>
Since: ${new Date(start).toLocaleTimeString("en-GB", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
})} <span class="elapsed"></span> })} <span class="elapsed"></span></span>
</span> </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>` </div>`
: ""; : "";
const progressBar = const progressBar =
progress !== null progress !== null
? ` ? `<div class="progress-bar" data-start="${start}" data-end="${end}">
<div class="progress-bar" data-start="${start}" data-end="${end}">
<div class="progress-fill" style="width: ${progress}%"></div> <div class="progress-fill" style="width: ${progress}%"></div>
</div> </div>
<div class="progress-time-labels" data-start="${start}" data-end="${end}"> <div class="progress-time-labels" data-start="${start}" data-end="${end}">
<span class="progress-current">${formatTime(elapsed)}</span> <span class="progress-current">${formatTime(elapsed)}</span>
<span class="progress-total">${formatTime(total)}</span> <span class="progress-total">${formatTime(total)}</span>
</div> </div>`
`
: ""; : "";
const activityButtons = activity.buttons && activity.buttons.length > 0 const isMusic = activity.type === 2 || activity.type === 3;
? `<div class="activity-buttons">
${activity.buttons.map((button, index) => { const primaryLine = isMusic ? activity.details : activity.name;
const buttonLabel = typeof button === 'string' ? button : button.label; const secondaryLine = isMusic ? activity.state : activity.details;
let buttonUrl = null; const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
if (typeof button === 'object' && button.url) {
buttonUrl = button.url; const activityArt = art
} ? `<div class="activity-image-wrapper">
else if (index === 0 && activity.url) { <img class="activity-image" src="${art}" alt="Art" ${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}>
buttonUrl = activity.url; ${smallArt ? `<img class="activity-image-small" src="${smallArt}" alt="Small Art" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>` : ""}
}
if (buttonUrl) {
return `<a href="${buttonUrl}" class="activity-button" target="_blank" rel="noopener noreferrer">${buttonLabel}</a>`;
} else {
return `<span class="activity-button disabled">${buttonLabel}</span>`;
}
}).join('')}
</div>` </div>`
: ''; : "";
return ` return `
<li class="activity"> <li class="activity">
${art ? `<img class="activity-art" src="${art}" alt="Art">` : ""} <div class="activity-wrapper">
<div class="activity-content"> <div class="activity-type-wrapper">
<div class="activity-header ${progress !== null ? "no-timestamp" : ""}"> <span class="activity-type-label" data-type="${activity.type}">${activityType}</span>
<span class="activity-name">${activity.name}</span>
${activityTimestamp} ${activityTimestamp}
</div> </div>
${activity.details ? `<div class="activity-detail">${activity.details}</div>` : ""} <div class="activity-wrapper-inner">
${activity.state ? `<div class="activity-detail">${activity.state}</div>` : ""} ${activityArt}
<div class="activity-content">
<div class="inner-content">
<div class="activity-top">
<div class="activity-header ${progress !== null ? "no-timestamp" : ""}">
<span class="activity-name">${primaryLine}</span>
</div>
${secondaryLine ? `<div class="activity-detail">${secondaryLine}</div>` : ""}
${tertiaryLine ? `<div class="activity-detail">${tertiaryLine}</div>` : ""}
</div>
<div class="activity-bottom">
${activityButtons} ${activityButtons}
</div>
</div>
</div>
</div>
${progressBar} ${progressBar}
</div> </div>
</li> </li>
`; `;
} }
if (badgeURL && badgeURL !== "null" && userId) {
if (!badgeURL.startsWith("http")) {
badgeURL = `https://${badgeURL}`;
}
if (!badgeURL.endsWith("/")) {
badgeURL += "/";
}
async function loadBadges(userId, options = {}) {
const {
services = [],
seperated = false,
cache = true,
targetId = "badges",
} = 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) {
target.textContent = "Failed to load badges.";
return;
}
const badges = Array.isArray(json.badges)
? json.badges
: Object.values(json.badges).flat();
if (badges.length === 0) {
target.innerHTML = "";
target.classList.add("hidden");
return;
}
target.innerHTML = "";
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);
}
target.classList.remove("hidden");
} catch (err) {
console.error(err);
target.innerHTML = "";
target.classList.add("hidden");
}
}
loadBadges(userId, {
services: [],
seperated: false,
cache: true,
targetId: "badges",
});
}
function updatePresence(data) { function updatePresence(data) {
const avatarWrapper = document.querySelector(".avatar-wrapper"); const avatarWrapper = document.querySelector(".avatar-wrapper");
const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); const statusIndicator = avatarWrapper?.querySelector(".status-indicator");
const mobileIcon = avatarWrapper?.querySelector( const mobileIcon = avatarWrapper?.querySelector(".platform-icon.mobile-only");
".platform-icon.mobile-only",
);
const userInfo = document.querySelector(".user-info"); const userInfo = document.querySelector(".user-info");
const customStatus = userInfo?.querySelector(".custom-status"); const customStatus = userInfo?.querySelector(".custom-status");
@ -209,8 +364,15 @@ function updatePresence(data) {
desktop: data.active_on_discord_desktop, desktop: data.active_on_discord_desktop,
}; };
let status = "offline";
if (data.activities.some((activity) => activity.type === 1)) {
status = "streaming";
} else {
status = data.discord_status;
}
if (statusIndicator) { if (statusIndicator) {
statusIndicator.className = `status-indicator ${data.discord_status}`; statusIndicator.className = `status-indicator ${status}`;
} }
if (platform.mobile && !mobileIcon) { if (platform.mobile && !mobileIcon) {
@ -221,7 +383,7 @@ function updatePresence(data) {
`; `;
} else if (!platform.mobile && mobileIcon) { } else if (!platform.mobile && mobileIcon) {
mobileIcon.remove(); mobileIcon.remove();
avatarWrapper.innerHTML += `<div class="status-indicator ${data.discord_status}"></div>`; avatarWrapper.innerHTML += `<div class="status-indicator ${status}"></div>`;
} }
const custom = data.activities?.find((a) => a.type === 4); const custom = data.activities?.find((a) => a.type === 4);
@ -234,16 +396,31 @@ function updatePresence(data) {
} else if (emoji?.name) { } else if (emoji?.name) {
emojiHTML = `${emoji.name} `; emojiHTML = `${emoji.name} `;
} }
customStatus.innerHTML = `${emojiHTML}${custom.state}`; customStatus.innerHTML = `
${emojiHTML}
${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
`;
} }
const filtered = data.activities?.filter((a) => a.type !== 4); const filtered = data.activities
const activityList = document.querySelector(".activities"); ?.filter((a) => a.type !== 4)
?.sort((a, b) => {
const priority = { 2: 0, 1: 1, 3: 2 }; // Listening, Streaming, Watching ? should i keep this
const aPriority = priority[a.type] ?? 99;
const bPriority = priority[b.type] ?? 99;
return aPriority - bPriority;
});
if (activityList) { const activityList = document.querySelector(".activities");
activityList.innerHTML = ""; const activitiesTitle = document.querySelector(".activity-header");
if (activityList && activitiesTitle) {
if (filtered?.length) { if (filtered?.length) {
activityList.innerHTML = filtered.map(buildActivityHTML).join(""); activityList.innerHTML = filtered.map(buildActivityHTML).join("");
activitiesTitle.classList.remove("hidden");
} else {
activityList.innerHTML = "";
activitiesTitle.classList.add("hidden");
} }
updateElapsedAndProgress(); updateElapsedAndProgress();
} }

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

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

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

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

View file

@ -1,6 +1,6 @@
export function timestampToReadable(timestamp?: number): string { export function timestampToReadable(timestamp?: number): string {
const date: Date = const date: Date =
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
if (isNaN(date.getTime())) return "Invalid Date"; if (Number.isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", ""); 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("")}`;
}

View file

@ -1,5 +1,5 @@
import { resolve } from "node:path";
import { renderFile } from "ejs"; import { renderFile } from "ejs";
import { resolve } from "path";
export async function renderEjsTemplate( export async function renderEjsTemplate(
viewName: string | string[], viewName: string | string[],

View file

@ -1,5 +1,6 @@
import { lanyardConfig } from "@config/environment"; import { lanyardConfig } from "@config/environment";
import { fetch } from "bun"; import { fetch } from "bun";
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked"; import { marked } from "marked";
export async function getLanyardData(id?: string): Promise<LanyardResponse> { export async function getLanyardData(id?: string): Promise<LanyardResponse> {
@ -48,11 +49,12 @@ export async function getLanyardData(id?: string): Promise<LanyardResponse> {
export async function handleReadMe(data: LanyardData): Promise<string | null> { export async function handleReadMe(data: LanyardData): Promise<string | null> {
const userReadMe: string | null = data.kv?.readme; const userReadMe: string | null = data.kv?.readme;
const validExtension = /\.(md|markdown|txt|html?)$/i;
if ( if (
!userReadMe || !userReadMe ||
!userReadMe.toLowerCase().endsWith("readme.md") || !userReadMe.startsWith("http") ||
!userReadMe.startsWith("http") !validExtension.test(userReadMe)
) { ) {
return null; return null;
} }
@ -64,18 +66,10 @@ export async function handleReadMe(data: LanyardData): Promise<string | null> {
}, },
}); });
const contentType: string = res.headers.get("content-type") || ""; if (!res.ok) return null;
if (
!res.ok ||
!(
contentType.includes("text/markdown") ||
contentType.includes("text/plain")
)
)
return null;
if (res.headers.has("content-length")) { if (res.headers.has("content-length")) {
const size: number = parseInt( const size: number = Number.parseInt(
res.headers.get("content-length") || "0", res.headers.get("content-length") || "0",
10, 10,
); );
@ -85,7 +79,18 @@ export async function handleReadMe(data: LanyardData): Promise<string | null> {
const text: string = await res.text(); const text: string = await res.text();
if (!text || text.length < 10) return null; if (!text || text.length < 10) return null;
return marked.parse(text); let html: string;
if (
userReadMe.toLowerCase().endsWith(".html") ||
userReadMe.toLowerCase().endsWith(".htm")
) {
html = text;
} else {
html = await marked.parse(text);
}
const safe: string | null = DOMPurify.sanitize(html);
return safe;
} catch { } catch {
return null; return null;
} }

View file

@ -1,15 +1,15 @@
import { environment } from "@config/environment"; import type { Stats } from "node:fs";
import { timestampToReadable } from "@helpers/char";
import type { Stats } from "fs";
import { import {
type WriteStream,
createWriteStream, createWriteStream,
existsSync, existsSync,
mkdirSync, mkdirSync,
statSync, statSync,
WriteStream, } from "node:fs";
} from "fs"; import { EOL } from "node:os";
import { EOL } from "os"; import { basename, join } from "node:path";
import { basename, join } from "path"; import { environment } from "@config/environment";
import { timestampToReadable } from "@helpers/char";
class Logger { class Logger {
private static instance: Logger; private static instance: Logger;
@ -37,7 +37,7 @@ class Logger {
mkdirSync(logDir, { recursive: true }); mkdirSync(logDir, { recursive: true });
} }
let addSeparator: boolean = false; let addSeparator = false;
if (existsSync(logFile)) { if (existsSync(logFile)) {
const fileStats: Stats = statSync(logFile); const fileStats: Stats = statSync(logFile);
@ -66,9 +66,9 @@ class Logger {
private extractFileName(stack: string): string { private extractFileName(stack: string): string {
const stackLines: string[] = stack.split("\n"); const stackLines: string[] = stack.split("\n");
let callerFile: string = ""; let callerFile = "";
for (let i: number = 2; i < stackLines.length; i++) { for (let i = 2; i < stackLines.length; i++) {
const line: string = stackLines[i].trim(); const line: string = stackLines[i].trim();
if (line && !line.includes("Logger.") && line.includes("(")) { if (line && !line.includes("Logger.") && line.includes("(")) {
callerFile = line.split("(")[1]?.split(")")[0] || ""; callerFile = line.split("(")[1]?.split(")")[0] || "";
@ -91,7 +91,7 @@ class Logger {
return { filename, timestamp: readableTimestamp }; return { filename, timestamp: readableTimestamp };
} }
public info(message: string | string[], breakLine: boolean = false): void { public info(message: string | string[], breakLine = false): void {
const stack: string = new Error().stack || ""; const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack); const { filename, timestamp } = this.getCallerInfo(stack);
@ -110,7 +110,7 @@ class Logger {
this.writeConsoleMessageColored(logMessageParts, breakLine); this.writeConsoleMessageColored(logMessageParts, breakLine);
} }
public warn(message: string | string[], breakLine: boolean = false): void { public warn(message: string | string[], breakLine = false): void {
const stack: string = new Error().stack || ""; const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack); const { filename, timestamp } = this.getCallerInfo(stack);
@ -131,7 +131,7 @@ class Logger {
public error( public error(
message: string | Error | (string | Error)[], message: string | Error | (string | Error)[],
breakLine: boolean = false, breakLine = false,
): void { ): void {
const stack: string = new Error().stack || ""; const stack: string = new Error().stack || "";
const { filename, timestamp } = this.getCallerInfo(stack); const { filename, timestamp } = this.getCallerInfo(stack);
@ -161,7 +161,7 @@ class Logger {
bracketMessage2: string, bracketMessage2: string,
message: string | string[], message: string | string[],
color: string, color: string,
breakLine: boolean = false, breakLine = false,
): void { ): void {
const stack: string = new Error().stack || ""; const stack: string = new Error().stack || "";
const { timestamp } = this.getCallerInfo(stack); const { timestamp } = this.getCallerInfo(stack);
@ -189,7 +189,7 @@ class Logger {
private writeConsoleMessageColored( private writeConsoleMessageColored(
logMessageParts: ILogMessageParts, logMessageParts: ILogMessageParts,
breakLine: boolean = false, breakLine = false,
): void { ): void {
const logMessage: string = Object.keys(logMessageParts) const logMessage: string = Object.keys(logMessageParts)
.map((key: string) => { .map((key: string) => {

View file

@ -3,11 +3,7 @@ import { logger } from "@helpers/logger";
import { serverHandler } from "@/server"; import { serverHandler } from "@/server";
async function main(): Promise<void> { async function main(): Promise<void> {
try {
serverHandler.initialize(); serverHandler.initialize();
} catch (error) {
throw error;
}
} }
main().catch((error: Error) => { main().catch((error: Error) => {

View file

@ -1,4 +1,5 @@
import { lanyardConfig } from "@config/environment"; import { getImageColors } from "@/helpers/colors";
import { badgeApi, lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs"; import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard"; import { getLanyardData, handleReadMe } from "@helpers/lanyard";
@ -29,13 +30,28 @@ async function handler(request: ExtendedRequest): Promise<Response> {
} }
const presence: LanyardData = data.data; const presence: LanyardData = data.data;
const readme: string | Promise<string> | null = const readme: string | Promise<string> | null = await handleReadMe(presence);
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 = { const ejsTemplateData: EjsTemplateData = {
title: `${presence.discord_user.username || "Unknown"}`, title: presence.discord_user.global_name || presence.discord_user.username,
username: presence.discord_user.username, username:
status: presence.discord_status, presence.discord_user.global_name || presence.discord_user.username,
status: status,
activities: presence.activities, activities: presence.activities,
user: presence.discord_user, user: presence.discord_user,
platform: { platform: {
@ -45,6 +61,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}, },
instance, instance,
readme, readme,
allowSnow: presence.kv.snow === "true",
allowRain: presence.kv.rain === "true",
colors: colors?.colors ?? {},
badgeApi: badgeApi,
}; };
return await renderEjsTemplate("index", ejsTemplateData); return await renderEjsTemplate("index", ejsTemplateData);

View file

@ -1,7 +1,4 @@
import { fetch } from "bun"; import { getImageColors } from "@helpers/colors";
import { Vibrant } from "node-vibrant/node";
type Palette = Awaited<ReturnType<typeof Vibrant.prototype.getPalette>>;
const routeDef: RouteDef = { const routeDef: RouteDef = {
method: "GET", method: "GET",
@ -12,49 +9,21 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
const { url } = request.query; const { url } = request.query;
if (!url) { const result: ImageColorResult | null = await getImageColors(url, true);
return Response.json({ error: "URL is required" }, { status: 400 }); 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": "*",
},
});
} }
if (typeof url !== "string" || !url.startsWith("http")) { const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(result));
return Response.json({ error: "Invalid URL" }, { status: 400 });
}
let res: Response;
try {
res = await fetch(url);
} catch {
return Response.json(
{ error: "Failed to fetch image" },
{ status: 500 },
);
}
if (!res.ok) {
return Response.json(
{ error: "Image fetch returned error" },
{ status: res.status },
);
}
const type: string | null = res.headers.get("content-type");
if (!type?.startsWith("image/")) {
return Response.json({ error: "Not an image" }, { status: 400 });
}
const buffer: Buffer = Buffer.from(await res.arrayBuffer());
const base64: string = buffer.toString("base64");
const colors: Palette = await Vibrant.from(buffer).getPalette();
const payload: {
img: string;
colors: Palette;
} = {
img: `data:${type};base64,${base64}`,
colors,
};
const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(payload));
return new Response(compressed, { return new Response(compressed, {
headers: { headers: {

View file

@ -1,4 +1,5 @@
import { lanyardConfig } from "@config/environment"; import { getImageColors } from "@/helpers/colors";
import { badgeApi, lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs"; import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard"; import { getLanyardData, handleReadMe } from "@helpers/lanyard";
@ -28,15 +29,28 @@ async function handler(): Promise<Response> {
} }
const presence: LanyardData = data.data; const presence: LanyardData = data.data;
const readme: string | Promise<string> | null = const readme: string | Promise<string> | null = await handleReadMe(presence);
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 = { const ejsTemplateData: EjsTemplateData = {
title: title: presence.discord_user.global_name || presence.discord_user.username,
presence.discord_user.global_name || presence.discord_user.username,
username: username:
presence.discord_user.global_name || presence.discord_user.username, presence.discord_user.global_name || presence.discord_user.username,
status: presence.discord_status, status: status,
activities: presence.activities, activities: presence.activities,
user: presence.discord_user, user: presence.discord_user,
platform: { platform: {
@ -46,6 +60,10 @@ async function handler(): Promise<Response> {
}, },
instance, instance,
readme, readme,
allowSnow: presence.kv.snow === "true",
allowRain: presence.kv.rain === "true",
colors: colors?.colors ?? {},
badgeApi: badgeApi,
}; };
return await renderEjsTemplate("index", ejsTemplateData); return await renderEjsTemplate("index", ejsTemplateData);

View file

@ -1,3 +1,4 @@
import { resolve } from "node:path";
import { environment } from "@config/environment"; import { environment } from "@config/environment";
import { logger } from "@helpers/logger"; import { logger } from "@helpers/logger";
import { import {
@ -6,7 +7,6 @@ import {
type MatchedRoute, type MatchedRoute,
type Serve, type Serve,
} from "bun"; } from "bun";
import { resolve } from "path";
import { webSocketHandler } from "@/websocket"; import { webSocketHandler } from "@/websocket";
@ -34,22 +34,16 @@ class ServerHandler {
open: webSocketHandler.handleOpen.bind(webSocketHandler), open: webSocketHandler.handleOpen.bind(webSocketHandler),
message: webSocketHandler.handleMessage.bind(webSocketHandler), message: webSocketHandler.handleMessage.bind(webSocketHandler),
close: webSocketHandler.handleClose.bind(webSocketHandler), close: webSocketHandler.handleClose.bind(webSocketHandler),
error(error) {
logger.error(`Server error: ${error.message}`);
return new Response(`Server Error: ${error.message}`, {
status: 500,
});
},
}, },
}); });
const accessUrls = [ const accessUrls: string[] = [
`http://${server.hostname}:${server.port}`, `http://${server.hostname}:${server.port}`,
`http://localhost:${server.port}`, `http://localhost:${server.port}`,
`http://127.0.0.1:${server.port}`, `http://127.0.0.1:${server.port}`,
]; ];
logger.info(`Server running at ${accessUrls[0]}`, true); logger.info(`Server running at ${accessUrls[0]}`);
logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true);
this.logRoutes(); this.logRoutes();
@ -83,21 +77,16 @@ class ServerHandler {
if (await file.exists()) { if (await file.exists()) {
const fileContent: ArrayBuffer = await file.arrayBuffer(); const fileContent: ArrayBuffer = await file.arrayBuffer();
const contentType: string = const contentType: string = file.type || "application/octet-stream";
file.type || "application/octet-stream";
return new Response(fileContent, { return new Response(fileContent, {
headers: { "Content-Type": contentType }, headers: { "Content-Type": contentType },
}); });
} else { }
logger.warn(`File not found: ${filePath}`); logger.warn(`File not found: ${filePath}`);
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
}
} catch (error) { } catch (error) {
logger.error([ logger.error([`Error serving static file: ${pathname}`, error as Error]);
`Error serving static file: ${pathname}`,
error as Error,
]);
return new Response("Internal Server Error", { status: 500 }); return new Response("Internal Server Error", { status: 500 });
} }
} }
@ -123,8 +112,7 @@ class ServerHandler {
try { try {
const routeModule: RouteModule = await import(filePath); const routeModule: RouteModule = await import(filePath);
const contentType: string | null = const contentType: string | null = request.headers.get("Content-Type");
request.headers.get("Content-Type");
const actualContentType: string | null = contentType const actualContentType: string | null = contentType
? contentType.split(";")[0].trim() ? contentType.split(";")[0].trim()
: null; : null;
@ -151,9 +139,7 @@ class ServerHandler {
if ( if (
(Array.isArray(routeModule.routeDef.method) && (Array.isArray(routeModule.routeDef.method) &&
!routeModule.routeDef.method.includes( !routeModule.routeDef.method.includes(request.method)) ||
request.method,
)) ||
(!Array.isArray(routeModule.routeDef.method) && (!Array.isArray(routeModule.routeDef.method) &&
routeModule.routeDef.method !== request.method) routeModule.routeDef.method !== request.method)
) { ) {
@ -178,9 +164,7 @@ class ServerHandler {
if (Array.isArray(expectedContentType)) { if (Array.isArray(expectedContentType)) {
matchesAccepts = matchesAccepts =
expectedContentType.includes("*/*") || expectedContentType.includes("*/*") ||
expectedContentType.includes( expectedContentType.includes(actualContentType || "");
actualContentType || "",
);
} else { } else {
matchesAccepts = matchesAccepts =
expectedContentType === "*/*" || expectedContentType === "*/*" ||
@ -219,10 +203,7 @@ class ServerHandler {
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
logger.error([ logger.error([`Error handling route ${request.url}:`, error as Error]);
`Error handling route ${request.url}:`,
error as Error,
]);
response = Response.json( response = Response.json(
{ {
@ -244,15 +225,15 @@ class ServerHandler {
); );
} }
const headers: Headers = response.headers; const headers = request.headers;
let ip: string | null = server.requestIP(request)?.address || null; let ip = server.requestIP(request)?.address;
if (!ip) { if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip = ip =
headers.get("CF-Connecting-IP") || headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP") || headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For") || headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
null; "unknown";
} }
logger.custom( logger.custom(

View file

@ -1,20 +1,36 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head data-user-id="<%= user.id %>" data-instance-uri="<%= instance %>">
<head data-user-id="<%= user.id %>" data-instance-uri="<%= instance %>" data-badge-url="<%= badgeApi %>">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="<%= username %>'s Presence"> <%
const displayName = username.endsWith('s') ? `${username}'` : `${username}'s`;
const profileUrl = `https://discord.com/users/${user.id}`;
%>
<meta property="og:title" content="<%= displayName %> Discord Presence">
<meta property="og:description" content="<%= activities?.[0]?.state || 'See what ' + displayName + ' is doing on Discord.' %>">
<meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>"> <meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>">
<meta property="og:description" content="<%= activities[0]?.state || 'Discord Presence' %>"> <meta name="twitter:card" content="summary_large_image">
<title><%= title %></title> <title><%= title %></title>
<link rel="stylesheet" href="/public/css/index.css"> <link rel="stylesheet" href="/public/css/index.css">
<script src="/public/js/index.js" defer></script> <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"> <meta name="color-scheme" content="dark">
</head> </head>
<%- include("partial/style.ejs") %>
<body> <body>
<div class="user-card"> <div class="user-card">
<div class="avatar-status-wrapper"> <div class="avatar-status-wrapper">
@ -25,14 +41,22 @@
<% } %> <% } %>
<% if (platform.mobile) { %> <% if (platform.mobile) { %>
<svg class="platform-icon mobile-only" viewBox="0 0 1000 1500" fill="#43a25a" aria-label="Mobile" width="17" height="17"> <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"/> <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> </svg>
<% } else { %> <% } else { %>
<div class="status-indicator <%= status %>"></div> <div class="status-indicator <%= status %>"></div>
<% } %> <% } %>
</div> </div>
<div class="user-info"> <div class="user-info">
<div class="user-info-inner">
<h1><%= username %></h1> <h1><%= username %></h1>
<% if (user.clan && user.clan.tag) { %>
<div class="clan-badge">
<img src="https://cdn.discordapp.com/clan-badges/<%= user.clan.identity_guild_id %>/<%= user.clan.badge %>" alt="Clan Badge" class="clan-badge">
<span class="clan-name"><%= user.clan.tag %></span>
</div>
<% } %>
</div>
<% if (activities.length && activities[0].type === 4) { <% if (activities.length && activities[0].type === 4) {
const emoji = activities[0].emoji; const emoji = activities[0].emoji;
const isCustom = emoji?.id; const isCustom = emoji?.id;
@ -46,16 +70,30 @@
<% } else if (emoji?.name) { %> <% } else if (emoji?.name) { %>
<%= emoji.name %> <%= emoji.name %>
<% } %> <% } %>
<%= activities[0].state %> <% if (activities[0].state) { %>
<span class="custom-status-text"><%= activities[0].state %></span>
<% } %>
</p> </p>
<% } %> <% } %>
</div> </div>
</div> </div>
</div> </div>
<% const filtered = activities.filter(a => a.type !== 4); %> <% if(badgeApi) { %>
<% if (filtered.length > 0) { %> <div id="badges" class="badges"></div>
<h2>Activities</h2> <% } %>
<%
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"> <ul class="activities">
<% filtered.forEach(activity => { <% filtered.forEach(activity => {
const start = activity.timestamps?.start; const start = activity.timestamps?.start;
@ -65,26 +103,48 @@
const total = (start && end) ? end - start : null; const total = (start && end) ? end - start : null;
const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null; const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null;
const img = activity.assets?.large_image;
let art = null; let art = null;
if (img?.includes("https")) { let smallArt = null;
const clean = img.split("/https/")[1];
if (clean) art = `https://${clean}`; function resolveActivityImage(img, applicationId) {
} else if (img?.startsWith("spotify:")) { if (!img) return null;
art = `https://i.scdn.co/image/${img.split(":")[1]}`; if (img.startsWith("mp:external/")) {
} else if (img) { return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`;
art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`;
} }
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"> <li class="activity">
<% if (art) { %> <div class="activity-wrapper">
<img class="activity-art" src="<%= art %>" alt="Art"> <div class="activity-type-wrapper">
<% } %> <span class="activity-type-label" data-type="<%= activity.type %>"><%= activityType %></span>
<div class="activity-content">
<div class="activity-header <%= progress !== null ? 'no-timestamp' : '' %>">
<span class="activity-name"><%= activity.name %></span>
<% if (start && progress === null) { %> <% if (start && progress === null) { %>
<div class="activity-timestamp" data-start="<%= start %>"> <div class="activity-timestamp" data-start="<%= start %>">
<% const started = new Date(start); %> <% const started = new Date(start); %>
@ -94,14 +154,35 @@
</div> </div>
<% } %> <% } %>
</div> </div>
<div class="activity-wrapper-inner">
<% if (activity.details) { %> <% if (art) { %>
<div class="activity-detail"><%= activity.details %></div> <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}"` : '' %>>
<% } %> <% } %>
<% if (activity.state) { %> </div>
<div class="activity-detail"><%= activity.state %></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) { %> <% if (activity.buttons && activity.buttons.length > 0) { %>
<div class="activity-buttons"> <div class="activity-buttons">
<% activity.buttons.forEach((button, index) => { <% activity.buttons.forEach((button, index) => {
@ -109,42 +190,41 @@
let buttonUrl = null; let buttonUrl = null;
if (typeof button === 'object' && button.url) { if (typeof button === 'object' && button.url) {
buttonUrl = button.url; buttonUrl = button.url;
} } else if (index === 0 && activity.url) {
else if (index === 0 && activity.url) {
buttonUrl = activity.url; buttonUrl = activity.url;
} }
%> %>
<% if (buttonUrl) { %> <% if (buttonUrl) { %>
<a href="<%= buttonUrl %>" class="activity-button" target="_blank" rel="noopener noreferrer"><%= buttonLabel %></a> <a href="<%= buttonUrl %>" class="activity-button" target="_blank" rel="noopener noreferrer"><%= buttonLabel %></a>
<% } else { %>
<span class="activity-button disabled"><%= buttonLabel %></span>
<% } %> <% } %>
<% }); %> <% }) %>
</div> </div>
<% } %> <% } %>
</div>
</div>
</div>
</div>
<% if (progress !== null) { %> <% if (progress !== null) { %>
<div class="progress-bar" data-start="<%= start %>" data-end="<%= end %>"> <div class="progress-bar" data-start="<%= start %>" data-end="<%= end %>">
<div class="progress-fill" <%= progress !== null ? `style="width: ${progress}%"` : '' %>></div> <div class="progress-fill" <%= progress !== null ? `style="width: ${progress}%"` : '' %>></div>
</div> </div>
<% if (start && end) { %> <% if (start && end) { %>
<div class="progress-time-labels" data-start="<%= start %>" data-end="<%= end %>"> <div class="progress-time-labels" data-start="<%= start %>" data-end="<%= end %>">
<span class="progress-current">--:--</span> <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> <span class="progress-total"><%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %></span>
</div> </div>
<% } %> <% } %>
<% } %> <% } %>
</div> </div>
</li> </li>
<% }) %> <% }); %>
</ul> </ul>
<% } %>
<% if (readme) { %> <% if (readme) { %>
<h2>Readme</h2>
<section class="readme"> <section class="readme">
<div class="markdown-body"><%- readme %></div> <div class="markdown-body"><%- readme %></div>
</section> </section>
<% } %> <% } %>
</body> </body>
</html> </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,5 +1,5 @@
import { logger } from "@helpers/logger"; import { logger } from "@helpers/logger";
import { type ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
class WebSocketHandler { class WebSocketHandler {
public handleMessage(ws: ServerWebSocket, message: string): void { public handleMessage(ws: ServerWebSocket, message: string): void {
@ -20,11 +20,7 @@ class WebSocketHandler {
} }
} }
public handleClose( public handleClose(ws: ServerWebSocket, code: number, reason: string): void {
ws: ServerWebSocket,
code: number,
reason: string,
): void {
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
} }
} }

View file

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

7
types/routes.d.ts vendored
View file

@ -13,3 +13,10 @@ type RouteModule = {
) => Promise<Response> | Response; ) => Promise<Response> | Response;
routeDef: RouteDef; 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>;
};