forked from creations/profilePage
Compare commits
19 commits
Author | SHA1 | Date | |
---|---|---|---|
8b7bedbf0b | |||
bf66b301ae | |||
7816210a2c | |||
b109f67125 | |||
23f37beef3 | |||
7f9f166f8a | |||
59b354e43c | |||
59d3a6b3e2 | |||
0d5fbe76b7 | |||
ff0ece9626 | |||
30e9057ba8 | |||
f4aeb7aafb | |||
5e94af5980 | |||
c54d959e7e | |||
78c2eb4545 | |||
66744ddd10 | |||
d91e832eab | |||
7d78a74a25 | |||
c79ee2b203 |
29 changed files with 1272 additions and 569 deletions
|
@ -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
|
||||||
|
|
24
.forgejo/workflows/biomejs.yml
Normal file
24
.forgejo/workflows/biomejs.yml
Normal 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
21
LICENSE
Normal 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.
|
89
README.md
89
README.md
|
@ -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
41
biome.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
132
eslint.config.js
132
eslint.config.js
|
@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
22
package.json
22
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
77
public/js/rain.js
Normal 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
84
public/js/snow.js
Normal 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();
|
||||||
|
});
|
|
@ -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
49
src/helpers/colors.ts
Normal 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("")}`;
|
||||||
|
}
|
|
@ -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[],
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
31
src/views/partial/style.ejs
Normal file
31
src/views/partial/style.ejs
Normal 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>
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
7
types/routes.d.ts
vendored
|
@ -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>;
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue