diff --git a/.env.example b/.env.example
index d2b3d15..a10f7a4 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,24 @@
HOST=0.0.0.0
PORT=8080
+REDIS_URL=redis://dragonfly:6379
+REDIS_TTL=3600 # seconds
+
# this is only the default value if non is give in /id
LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest
+
+# Required if you want to enable badges
+BADGE_API_URL=http://localhost:8081
+
+# Required if you want to enable reviews from reviewdb
+REVIEW_DB=true
+
+#Timezone api url, aka: https://git.creations.works/creations/timezoneDB
+TIMEZONE_API_URL=
+
+# https://www.steamgriddb.com/api/v2, if you want games to have images
+STEAMGRIDDB_API_KEY=steamgrid_api_key
+
+# https://plausible.io
+PLAUSIBLE_SCRIPT_HTML=''
diff --git a/.forgejo/workflows/biomejs.yml b/.forgejo/workflows/biomejs.yml
new file mode 100644
index 0000000..15c990c
--- /dev/null
+++ b/.forgejo/workflows/biomejs.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index d0ef245..16498de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
/node_modules
bun.lock
.env
-logs/
\ No newline at end of file
+logs/
+.vscode/
+robots.txt
+public/custom
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 25117cc..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "github-enterprise.uri": "https://git.creations.works"
-}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2438f47
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# use the official Bun image
+# see all versions at https://hub.docker.com/r/oven/bun/tags
+FROM oven/bun:latest AS base
+WORKDIR /usr/src/app
+
+FROM base AS install
+RUN mkdir -p /temp/dev
+COPY package.json bun.lock /temp/dev/
+RUN cd /temp/dev && bun install --frozen-lockfile
+
+# install with --production (exclude devDependencies)
+RUN mkdir -p /temp/prod
+COPY package.json bun.lock /temp/prod/
+RUN cd /temp/prod && bun install --frozen-lockfile --production
+
+# copy node_modules from temp directory
+# then copy all (non-ignored) project files into the image
+FROM base AS prerelease
+COPY --from=install /temp/dev/node_modules node_modules
+COPY . .
+
+# [optional] tests & build
+ENV NODE_ENV=production
+
+# copy production dependencies and source code into final image
+FROM base AS release
+COPY --from=install /temp/prod/node_modules node_modules
+COPY --from=prerelease /usr/src/app/src ./src
+COPY --from=prerelease /usr/src/app/public ./public
+COPY --from=prerelease /usr/src/app/package.json .
+COPY --from=prerelease /usr/src/app/tsconfig.json .
+COPY --from=prerelease /usr/src/app/config ./config
+COPY --from=prerelease /usr/src/app/types ./types
+
+RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs
+
+USER bun
+ENTRYPOINT [ "bun", "run", "start" ]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d93a942
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2025, creations.works
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
index 48d4c50..f4b9394 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,145 @@
-# Cool little discord profile page
+# Discord Profile Page
-E
+A cool little web app that shows your Discord profile, current activity, and more. Built with Bun.
+
+# Preview
+https://creations.works
+
+---
+
+## Requirements
+
+This project depends on the following services to function properly:
+
+### 1. Lanyard Backend
+
+This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance to fetch real-time Discord presence data.
+Make sure the Lanyard instance is running and accessible before using this.
+
+### 2. Redis Instance
+
+A Redis-compatible key-value store is required to cache third-party data (e.g., SteamGridDB icons).
+I recommend [Dragonfly](https://www.dragonflydb.io/), a high-performance drop-in replacement for Redis.
+
+### 3. Badge API
+
+A lightweight API to render Discord-style badges.
+>Only needed if you want to show badges on profiles:
+https://git.creations.works/creations/badgeAPI
+
+### 4. SteamGridDB
+
+>Only needed if you want to fetch game icons that Discord doesn’t provide:
+https://www.steamgriddb.com/api/v2
+
+---
+
+## Getting Started
+
+### 1. Clone & Install
+
+```bash
+git clone https://git.creations.works/creations/profilePage.git
+cd profilePage
+bun install
+```
+
+### 2. Configure Environment
+
+Copy the example environment file and update it:
+
+```bash
+cp .env.example .env
+```
+
+#### `.env` Variables
+
+| Variable | Description |
+|-----------------------|-----------------------------------------------------------------------------|
+| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) |
+| `PORT` | Port to run the server on (default: `8080`) |
+| `REDIS_URL` | Redis connection string |
+| `LANYARD_USER_ID` | Your Discord user ID, for the default page |
+| `LANYARD_INSTANCE` | Endpoint of the Lanyard instance |
+| `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) |
+| `REVIEW_DB` | Enables showing reviews from reviewdb on user pages |
+| `TIMEZONE_API_URL` | Enables showing times from [timezoneDB](https://git.creations.works/creations/timezoneDB-rs) |
+| `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons |
+
+#### Optional Lanyard KV Variables (per-user customization)
+
+These can be defined in Lanyard's KV store to customize the page:
+
+| Variable | Description |
+|-----------|--------------------------------------------------------------------|
+| `snow` | Enables snow background (`true` / `false`) |
+| `rain` | Enables rain background (`true` / `false`) |
+| `stars` | Enables starfield background (`true` / `false`) |
+| `badges` | Enables badge fetching (`true` / `false`) |
+| `readme` | URL to a README displayed on the profile (`.md` or `.html`) |
+| `css` | URL to a css to change styles on the page, no import or require allowed |
+| `optout` | Allows users to stop sharing there profile on the website (`true` / `false`) |
+| `reviews` | Enables reviews from reviewdb (`true` / `false`) |
+| `timezone`| Enables the showing of the current time from the timezone db API (`true` / `false`) |
+| `timezone_12` | Sets the timezone to default show 12h format, default is 24h |
+
+---
+
+### 3. Start the Instance
+
+```bash
+bun run start
+```
+
+---
+
+## Optional: Analytics with Plausible
+
+You can enable [Plausible Analytics](https://plausible.io) tracking by setting a script snippet in your environment.
+
+### `.env` Variable
+
+| Variable | Description |
+|-------------------------|------------------------------------------------------------------------|
+| `PLAUSIBLE_SCRIPT_HTML` | Full `'
+```
+
+- The script will only be injected if this variable is set.
+- Plausible provides the correct script when you add a domain.
+- Be sure to wrap it in single quotes (`'`) so it works in `.env`.
+
+---
+
+## Docker Support
+
+### Build & Start with Docker Compose
+
+```bash
+docker compose up -d --build
+```
+
+Make sure the `.env` file is configured correctly before starting the container.
+
+---
+
+## Routes
+
+These are the main public routes exposed by the server:
+
+| Route | Description |
+|---------|-----------------------------------------------------------------------------|
+| `/` | Loads the profile page for the default Discord user defined in `.env` (`LANYARD_USER_ID`) |
+| `/[id]` | Loads the profile page for a specific Discord user ID passed in the URL |
+
+> Example: `https://creations.works/209830981060788225` shows the profile of that specific user.
+
+---
+
+## License
+
+[BSD 3](LICENSE)
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..46ee8c9
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": false
+ },
+ "files": {
+ "ignoreUnknown": true,
+ "ignore": []
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "tab",
+ "lineEnding": "lf"
+ },
+ "organizeImports": {
+ "enabled": true
+ },
+ "css": {
+ "formatter": {
+ "indentStyle": "tab",
+ "lineEnding": "lf"
+ }
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "correctness": {
+ "noUnusedImports": "error"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double",
+ "indentStyle": "tab",
+ "lineEnding": "lf",
+ "jsxQuoteStyle": "double",
+ "semicolons": "always"
+ }
+ }
+}
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 0000000..f2c01ff
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,29 @@
+services:
+ profile-page:
+ container_name: profilePage
+ build:
+ context: .
+ restart: unless-stopped
+ ports:
+ - "${PORT:-6600}:${PORT:-6600}"
+ env_file:
+ - .env
+ networks:
+ - profilePage-network
+
+ dragonfly:
+ image: 'docker.dragonflydb.io/dragonflydb/dragonfly'
+ restart: unless-stopped
+ ulimits:
+ memlock: -1
+ volumes:
+ - dragonflydata:/data
+ networks:
+ - profilePage-network
+
+volumes:
+ dragonflydata:
+
+networks:
+ profilePage-network:
+ driver: bridge
diff --git a/config/environment.ts b/config/environment.ts
index c584b45..2c89b09 100644
--- a/config/environment.ts
+++ b/config/environment.ts
@@ -1,12 +1,66 @@
-export const environment: Environment = {
- port: parseInt(process.env.PORT || "8080", 10),
+import { echo } from "@atums/echo";
+
+const environment: Environment = {
+ port: Number.parseInt(process.env.PORT || "8080", 10),
host: process.env.HOST || "0.0.0.0",
development:
- process.env.NODE_ENV === "development" ||
- process.argv.includes("--dev"),
+ process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
-export const lanyardConfig: LanyardConfig = {
+const redisTtl: number = process.env.REDIS_TTL
+ ? Number.parseInt(process.env.REDIS_TTL, 10)
+ : 60 * 60 * 1; // 1 hour
+
+const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "",
- instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
+ instance: process.env.LANYARD_INSTANCE || "",
+};
+
+const reviewDb = {
+ enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1",
+ url: "https://manti.vendicated.dev/api/reviewdb",
+};
+
+const timezoneAPIUrl: string | null = process.env.TIMEZONE_API_URL || null;
+
+const badgeApi: string | null = process.env.BADGE_API_URL || null;
+const steamGridDbKey: string | undefined = process.env.STEAMGRIDDB_API_KEY;
+
+const plausibleScript: string | null =
+ process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null;
+
+function verifyRequiredVariables(): void {
+ const requiredVariables = [
+ "HOST",
+ "PORT",
+
+ "LANYARD_USER_ID",
+ "LANYARD_INSTANCE",
+ ];
+
+ let hasError = false;
+
+ for (const key of requiredVariables) {
+ const value = process.env[key];
+ if (value === undefined || value.trim() === "") {
+ echo.error(`Missing or empty environment variable: ${key}`);
+ hasError = true;
+ }
+ }
+
+ if (hasError) {
+ process.exit(1);
+ }
+}
+
+export {
+ environment,
+ lanyardConfig,
+ redisTtl,
+ reviewDb,
+ timezoneAPIUrl,
+ badgeApi,
+ steamGridDbKey,
+ plausibleScript,
+ verifyRequiredVariables,
};
diff --git a/eslint.config.js b/eslint.config.js
deleted file mode 100644
index d43df76..0000000
--- a/eslint.config.js
+++ /dev/null
@@ -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,
- },
- ],
- },
- },
-];
diff --git a/logger.json b/logger.json
new file mode 100644
index 0000000..521b3bc
--- /dev/null
+++ b/logger.json
@@ -0,0 +1,39 @@
+{
+ "directory": "logs",
+ "level": "debug",
+ "disableFile": false,
+
+ "rotate": true,
+ "maxFiles": 3,
+
+ "console": true,
+ "consoleColor": true,
+
+ "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
+ "timezone": "local",
+
+ "silent": false,
+
+ "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}",
+ "levelColor": {
+ "debug": "blue",
+ "info": "green",
+ "warn": "yellow",
+ "error": "red",
+ "fatal": "red"
+ },
+
+ "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
+ "customColors": {
+ "GET": "green",
+ "POST": "blue",
+ "PUT": "yellow",
+ "DELETE": "red",
+ "PATCH": "cyan",
+ "HEAD": "magenta",
+ "OPTIONS": "white",
+ "TRACE": "gray"
+ },
+
+ "prettyPrint": true
+}
diff --git a/package.json b/package.json
index 7d18b41..5654ef1 100644
--- a/package.json
+++ b/package.json
@@ -5,31 +5,20 @@
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run --hot src/index.ts --dev",
- "lint": "eslint",
- "lint:fix": "bun lint --fix",
+ "lint": "bunx biome ci . --verbose",
+ "lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lockdb"
},
"devDependencies": {
- "@eslint/js": "^9.24.0",
+ "@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.8",
- "@types/ejs": "^3.1.5",
- "@typescript-eslint/eslint-plugin": "^8.29.0",
- "@typescript-eslint/parser": "^8.29.0",
- "eslint": "^9.24.0",
- "eslint-plugin-prettier": "^5.2.6",
- "eslint-plugin-promise": "^7.2.1",
- "eslint-plugin-simple-import-sort": "^12.1.1",
- "eslint-plugin-unicorn": "^56.0.1",
- "eslint-plugin-unused-imports": "^4.1.4",
- "globals": "^15.15.0",
- "prettier": "^3.5.3"
+ "globals": "^16.0.0"
},
"peerDependencies": {
"typescript": "^5.8.3"
},
"dependencies": {
- "ejs": "^3.1.10",
- "node-vibrant": "^4.0.3",
+ "@atums/echo": "^1.0.3",
"marked": "^15.0.7"
}
}
diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico
index 69ec50d..72ecc34 100644
Binary files a/public/assets/favicon.ico and b/public/assets/favicon.ico differ
diff --git a/public/assets/favicon.png b/public/assets/favicon.png
new file mode 100644
index 0000000..d65b7ec
Binary files /dev/null and b/public/assets/favicon.png differ
diff --git a/public/assets/forgejo_logo.svg b/public/assets/forgejo_logo.svg
new file mode 100644
index 0000000..be0b3ce
--- /dev/null
+++ b/public/assets/forgejo_logo.svg
@@ -0,0 +1,36 @@
+
\ No newline at end of file
diff --git a/public/css/error.css b/public/css/error.css
deleted file mode 100644
index a8e591d..0000000
--- a/public/css/error.css
+++ /dev/null
@@ -1,25 +0,0 @@
-body {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 90vh;
- background: #0e0e10;
- color: #fff;
- font-family: system-ui, sans-serif;
-}
-.error-container {
- text-align: center;
- padding: 2rem;
- background: #1a1a1d;
- border-radius: 12px;
- box-shadow: 0 0 20px rgba(0,0,0,0.3);
-}
-.error-title {
- font-size: 2rem;
- margin-bottom: 1rem;
- color: #ff4e4e;
-}
-.error-message {
- font-size: 1.2rem;
- opacity: 0.8;
-}
diff --git a/public/css/index.css b/public/css/index.css
index da470b9..8960831 100644
--- a/public/css/index.css
+++ b/public/css/index.css
@@ -1,7 +1,83 @@
+.raindrop {
+ position: absolute;
+ background-color: white;
+ border-radius: 50%;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.star,
+.snowflake {
+ position: absolute;
+ background-color: white;
+ border-radius: 50%;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.star {
+ animation: twinkle ease-in-out infinite alternate;
+}
+
+.shooting-star {
+ position: absolute;
+ background: linear-gradient(90deg, white, transparent);
+ width: 100px;
+ height: 2px;
+ opacity: 0.8;
+ border-radius: 2px;
+ transform-origin: left center;
+}
+
+@keyframes twinkle {
+ from {
+ opacity: 0.3;
+ transform: scale(1);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1.2);
+ }
+}
+
+#loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 99999;
+
+ transition: opacity 0.5s ease;
+}
+
+.loading-spinner {
+ width: 50px;
+ height: 50px;
+ border: 5px solid var(--border-color);
+ border-top: 5px solid var(--progress-fill);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+/* actual styles below */
body {
font-family: system-ui, sans-serif;
- background-color: #0e0e10;
- color: #ffffff;
+ background-color: var(--background);
+ color: var(--text-color);
margin: 0;
padding: 2rem;
display: flex;
@@ -9,19 +85,59 @@ body {
align-items: center;
}
+main {
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.open-source-logo {
+ width: 2rem;
+ height: 2rem;
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+
+ position: fixed;
+ bottom: 1rem;
+ right: 0.5rem;
+ z-index: 1000;
+
+ opacity: 0.5;
+ transition: opacity 0.3s ease;
+
+ &:hover {
+ opacity: 1 !important;
+ }
+}
+
+.hidden {
+ display: none !important;
+}
+
+.activity-header.hidden {
+ display: none;
+}
+
.user-card {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
- max-width: 600px;
+ max-width: 700px;
width: 100%;
}
.avatar-status-wrapper {
display: flex;
align-items: center;
- gap: 1.5rem;
+ gap: 2rem;
+
+ width: fit-content;
+ max-width: 700px;
}
.avatar-wrapper {
@@ -36,12 +152,35 @@ body {
border-radius: 50%;
}
+.badges {
+ max-width: 700px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: 0.3rem;
+ flex-wrap: wrap;
+ margin-top: 0.5rem;
+ padding: 0.5rem;
+ background-color: var(--card-bg);
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+}
+
+.badge {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+}
+
.decoration {
position: absolute;
- top: -18px;
- left: -18px;
- width: 164px;
- height: 164px;
+ top: -13px;
+ left: -16px;
+ width: 160px;
+ height: 160px;
pointer-events: none;
}
@@ -52,35 +191,58 @@ body {
width: 24px;
height: 24px;
border-radius: 50%;
- border: 4px solid #0e0e10;
+ border: 4px solid var(--background);
display: flex;
align-items: center;
justify-content: center;
}
.status-indicator.online {
- background-color: #23a55a;
+ background-color: var(--status-online);
}
.status-indicator.idle {
- background-color: #f0b232;
+ background-color: var(--status-idle);
}
.status-indicator.dnd {
- background-color: #f23f43;
+ background-color: var(--status-dnd);
}
.status-indicator.offline {
- background-color: #747f8d;
+ background-color: var(--status-offline);
+}
+
+.status-indicator.streaming {
+ background-color: var(--status-streaming);
}
.platform-icon.mobile-only {
position: absolute;
- bottom: 4px;
+ bottom: 0;
right: 4px;
width: 30px;
height: 30px;
pointer-events: none;
+ background-color: var(--background);
+ padding: 0.3rem 0.1rem;
+ border-radius: 8px;
+}
+
+.platform-icon.mobile-only.dnd {
+ fill: var(--status-dnd);
+}
+.platform-icon.mobile-only.idle {
+ fill: var(--status-idle);
+}
+.platform-icon.mobile-only.online {
+ fill: var(--status-online);
+}
+.platform-icon.mobile-only.offline {
+ fill: var(--status-offline);
+}
+.platform-icon.mobile-only.streaming {
+ fill: var(--status-streaming);
}
.user-info {
@@ -88,15 +250,65 @@ body {
flex-direction: column;
}
+.user-info-inner {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ text-align: center;
+ gap: 0.5rem;
+}
+.user-info-inner a {
+ text-decoration: none;
+ color: var(--link-color);
+}
+
+.user-info-inner h1 {
+ font-size: 2rem;
+ margin: 0;
+}
+
+.clan-badge {
+ width: fit-content;
+ height: fit-content;
+ border-radius: 8px;
+ background-color: var(--card-bg);
+
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 0.3rem;
+ padding: 0.3rem 0.5rem;
+
+ text-align: center;
+ align-items: center;
+ justify-content: center;
+}
+
+.clan-badge img {
+ width: 20px;
+ height: 20px;
+ margin: 0;
+ padding: 0;
+}
+
+.clan-badge span {
+ font-size: 0.9rem;
+ color: var(--text-color);
+ margin: 0;
+
+ font-weight: 600;
+}
+
h1 {
font-size: 2.5rem;
margin: 0;
- color: #00b0f4;
+ color: var(--link-color);
}
.custom-status {
font-size: 1.2rem;
- color: #bbb;
+ color: var(--text-subtle);
margin-top: 0.25rem;
word-break: break-word;
overflow-wrap: anywhere;
@@ -107,7 +319,6 @@ h1 {
flex-wrap: wrap;
}
-
.custom-status .custom-emoji {
width: 20px;
height: 20px;
@@ -125,7 +336,26 @@ ul {
list-style: none;
padding: 0;
width: 100%;
- max-width: 600px;
+ max-width: 700px;
+}
+
+.activities-section {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ width: 100%;
+ max-width: 700px;
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+}
+
+.activities-section .activity-block-header {
+ margin: 1rem 0 .5rem;
+ font-size: 2rem;
+ font-weight: 600;
+
+ text-align: center;
}
.activities {
@@ -133,7 +363,8 @@ ul {
flex-direction: column;
gap: 1rem;
width: 100%;
- max-width: 600px;
+ max-width: 700px;
+ box-sizing: border-box;
padding: 0;
margin: 0;
}
@@ -142,19 +373,57 @@ ul {
display: flex;
flex-direction: row;
gap: 1rem;
- background: #1a1a1d;
- padding: 1rem;
- border-radius: 6px;
- box-shadow: 0 0 0 1px #2e2e30;
- transition: background 0.2s ease;
- align-items: flex-start;
+ background-color: var(--card-bg);
+ padding: 0.75rem 1rem;
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+
+ transition: background-color 0.3s ease;
+
+ &:hover {
+ background: var(--card-hover-bg);
+ }
}
-.activity:hover {
- background: #2a2a2d;
+.activity-wrapper {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
}
-.activity-art {
+.activity-wrapper-inner {
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+}
+
+.activity-image-wrapper {
+ position: relative;
+ width: 80px;
+ height: 80px;
+}
+
+.no-asset {
+ display: none !important;
+}
+
+.activity-image-small {
+ width: 25px;
+ height: 25px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ border-color: var(--card-bg);
+ background-color: var(--card-bg);
+ border-width: 2px;
+ border-style: solid;
+
+ position: absolute;
+ bottom: -6px;
+ right: -10px;
+}
+
+.activity-image {
width: 80px;
height: 80px;
border-radius: 6px;
@@ -165,7 +434,15 @@ ul {
.activity-content {
display: flex;
flex-direction: column;
+ justify-content: space-between;
flex: 1;
+ gap: 0.5rem;
+ position: relative;
+}
+
+.activity-top {
+ display: flex;
+ flex-direction: column;
gap: 0.25rem;
}
@@ -175,64 +452,91 @@ ul {
align-items: flex-start;
}
+.activity-bottom {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
.activity-name {
- font-weight: bold;
- font-size: 1.1rem;
- color: #ffffff;
+ font-weight: 600;
+ font-size: 1rem;
+ color: var(--text-color);
}
.activity-detail {
- font-size: 0.95rem;
- color: #ccc;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
}
.activity-timestamp {
- font-size: 0.8rem;
- color: #777;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
text-align: right;
+ margin-left: auto;
+ white-space: nowrap;
}
.progress-bar {
- height: 6px;
- background-color: #333;
- border-radius: 3px;
+ height: 4px;
+ background-color: var(--border-color);
+ border-radius: 2px;
+ margin-top: 1rem;
overflow: hidden;
- width: 100%;
- margin-top: 0.5rem;
}
.progress-fill {
+ background-color: var(--progress-fill);
+ transition: width 0.4s ease;
height: 100%;
- background-color: #00b0f4;
- transition: width 0.5s ease;
+}
+
+.progress-bar,
+.progress-time-labels {
+ width: 100%;
}
.progress-time-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
- color: #888;
+ color: var(--text-muted);
margin-top: 0.25rem;
}
+.activity-type-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.activity-type-label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ font-weight: 600;
+ color: var(--blockquote-color);
+ margin-bottom: 0.5rem;
+ display: block;
+}
+
.activity-header.no-timestamp {
justify-content: flex-start;
}
.progress-time-labels.paused .progress-current::after {
content: " ⏸";
- color: #f0b232;
+ color: var(--status-idle);
}
.activity-buttons {
display: flex;
- flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
+ justify-content: flex-end;
}
.activity-button {
- background-color: #5865f2;
+ background-color: var(--progress-fill);
color: white;
border: none;
border-radius: 3px;
@@ -242,18 +546,17 @@ ul {
text-decoration: none;
transition: background-color 0.2s ease;
display: inline-block;
-}
-.activity-button:hover {
- background-color: #4752c4;
- text-decoration: none;
-}
+ &:hover {
+ background-color: var(--button-hover-bg);
+ text-decoration: none;
+ }
-.activity-button.disabled {
- background-color: #4e5058;
- cursor: default;
- pointer-events: none;
- opacity: 0.8;
+ &:disabled {
+ background-color: var(--button-disabled-bg);
+ cursor: not-allowed;
+ opacity: 0.8;
+ }
}
@media (max-width: 600px) {
@@ -270,6 +573,16 @@ ul {
.user-card {
width: 100%;
align-items: center;
+ margin-top: 2rem;
+ }
+
+ .badges {
+ max-width: 100%;
+ border-radius: 0;
+ border: none;
+ background-color: transparent;
+ margin-top: 0;
+ box-shadow: none;
}
.avatar-status-wrapper {
@@ -280,6 +593,13 @@ ul {
width: 100%;
}
+ .activity-image-wrapper {
+ max-width: 100%;
+ max-height: 100%;
+ width: 100%;
+ height: 100%;
+ }
+
.avatar-wrapper {
width: 96px;
height: 96px;
@@ -334,21 +654,31 @@ ul {
align-items: center;
text-align: center;
padding: 1rem;
- border-radius:0;
+ border-radius: 0;
}
- .activity-art {
+ .activity-image {
width: 100%;
- max-width: 300px;
+ max-width: 100%;
height: auto;
border-radius: 8px;
}
+ .activity-image-small {
+ width: 40px;
+ height: 40px;
+ }
+
.activity-content {
width: 100%;
align-items: center;
}
+ .activity-wrapper-inner {
+ flex-direction: column;
+ align-items: center;
+ }
+
.activity-header {
flex-direction: column;
align-items: center;
@@ -385,12 +715,16 @@ ul {
/* readme :p */
.readme {
- max-width: 600px;
+ max-width: fit-content;
+ min-width: 700px;
+ overflow: hidden;
width: 100%;
- background: #1a1a1d;
+ background: var(--readme-bg);
padding: 1.5rem;
border-radius: 8px;
- box-shadow: 0 0 0 1px #2e2e30;
+ border: 1px solid var(--border-color);
+
+ margin-top: 1rem;
box-sizing: border-box;
overflow: hidden;
@@ -399,13 +733,13 @@ ul {
.readme h2 {
margin-top: 0;
- color: #00b0f4;
+ color: var(--link-color);
}
.markdown-body {
font-size: 1rem;
line-height: 1.6;
- color: #ddd;
+ color: var(--text-color);
}
.markdown-body h1,
@@ -414,7 +748,7 @@ ul {
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
- color: #ffffff;
+ color: var(--text-color);
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
@@ -424,7 +758,7 @@ ul {
}
.markdown-body a {
- color: #00b0f4;
+ color: var(--link-color);
text-decoration: none;
}
@@ -433,7 +767,7 @@ ul {
}
.markdown-body code {
- background: #2e2e30;
+ background: var(--border-color);
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: monospace;
@@ -441,7 +775,7 @@ ul {
}
.markdown-body pre {
- background: #2e2e30;
+ background: var(--border-color);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
@@ -456,9 +790,9 @@ ul {
}
.markdown-body blockquote {
- border-left: 4px solid #00b0f4;
+ border-left: 4px solid var(--link-color);
padding-left: 1rem;
- color: #aaa;
+ color: var(--blockquote-color);
margin: 1rem 0;
}
@@ -468,7 +802,8 @@ ul {
@media (max-width: 600px) {
.readme {
- width: 100%;
+ max-width: 100%;
+ min-width: 100%;
padding: 1rem;
margin-top: 1rem;
@@ -479,3 +814,218 @@ ul {
font-size: 0.95rem;
}
}
+
+/* reviews */
+.reviews {
+ width: 100%;
+ max-width: 700px;
+ margin-top: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ background-color: var(--card-bg);
+ padding: 1rem;
+ border-radius: 10px;
+ border: 1px solid var(--border-color);
+ box-sizing: border-box;
+}
+
+.reviews h2 {
+ margin: 0 0 1rem;
+ font-size: 2rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+.reviews-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.review {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ transition: background-color 0.3s ease;
+}
+
+.review:hover {
+ background-color: var(--card-hover-bg);
+}
+
+.review-avatar {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.review-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.review-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.review-header-inner {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.review-username {
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+.review-timestamp {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.review-content {
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+
+.review-badges {
+ display: flex;
+ gap: 0.3rem;
+ flex-wrap: wrap;
+}
+
+.emoji {
+ width: 20px;
+ height: 20px;
+ vertical-align: middle;
+ margin: 0 2px;
+ display: inline-block;
+ transition: transform 0.3s ease;
+}
+
+.emoji:hover {
+ transform: scale(1.2);
+}
+
+.review-content img.emoji {
+ vertical-align: middle;
+}
+
+@media (max-width: 600px) {
+ .reviews {
+ max-width: 100%;
+ padding: 1rem;
+ border-radius: 0;
+ border: none;
+ background-color: transparent;
+ }
+
+ .reviews h2 {
+ font-size: 1.4rem;
+ text-align: center;
+ margin-bottom: 1rem;
+ }
+
+ .reviews-list {
+ gap: 0.75rem;
+ }
+
+ .review {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 1rem;
+ border-radius: 0;
+ }
+
+ .review-avatar {
+ width: 64px;
+ height: 64px;
+ }
+
+ .review-body {
+ width: 100%;
+ align-items: center;
+ }
+
+ .review-header {
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ }
+
+ .review-username {
+ font-size: 1rem;
+ }
+
+ .review-timestamp {
+ font-size: 0.75rem;
+ }
+
+ .review-content {
+ font-size: 0.9rem;
+ }
+
+ .review-badges {
+ justify-content: center;
+ }
+
+ .emoji {
+ width: 16px;
+ height: 16px;
+ }
+}
+
+/* timezone display */
+
+.timezone-wrapper {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background-color: var(--card-bg);
+ color: var(--text-color);
+ font-size: 0.9rem;
+ padding: 0.4rem 0.8rem;
+ border-radius: 6px;
+ border: 1px solid var(--border-color);
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
+ z-index: 100;
+ user-select: none;
+ opacity: 0.85;
+ transition: opacity 0.2s ease;
+}
+
+.timezone-wrapper:hover {
+ opacity: 1;
+}
+
+.timezone-label {
+ color: var(--text-muted);
+ margin-right: 0.4rem;
+}
+
+@media (max-width: 600px) {
+ .timezone-label {
+ display: none;
+ }
+}
diff --git a/public/css/root.css b/public/css/root.css
new file mode 100644
index 0000000..dec65f3
--- /dev/null
+++ b/public/css/root.css
@@ -0,0 +1,29 @@
+:root {
+ --background: #0e0e10;
+ --readme-bg: #1a1a1d;
+ --card-bg: #1e1f22;
+ --card-hover-bg: #2a2a2d;
+ --border-color: #2e2e30;
+
+ --text-color: #ffffff;
+ --text-subtle: #bbb;
+ --text-secondary: #b5bac1;
+ --text-muted: #888;
+ --link-color: #00b0f4;
+
+ --button-bg: #5865f2;
+ --button-hover-bg: #4752c4;
+ --button-disabled-bg: #2d2e31;
+
+ --progress-bg: #f23f43;
+ --progress-fill: #5865f2;
+
+ --status-online: #23a55a;
+ --status-idle: #f0b232;
+ --status-dnd: #e03e3e;
+ --status-offline: #747f8d;
+ --status-streaming: #b700ff;
+
+ --blockquote-color: #aaa;
+ --code-bg: #2e2e30;
+}
diff --git a/public/js/index.js b/public/js/index.js
index 6feec74..68e05b6 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -1,36 +1,60 @@
-/* eslint-disable indent */
-
+const head = document.querySelector("head");
+const userId = head?.dataset.userId;
const activityProgressMap = new Map();
+const reviewURL = head?.dataset.reviewDb;
+const timezoneApiUrl = head?.dataset.timezoneApi;
+let instanceUri = head?.dataset.instanceUri;
+let badgeURL = head?.dataset.badgeUrl;
+let socket;
+
+let badgesLoaded = false;
+let readmeLoaded = false;
+let cssLoaded = false;
+let timezoneLoaded = false;
+
+const reviewsPerPage = 50;
+let currentReviewOffset = 0;
+let hasMoreReviews = true;
+let isLoadingReviews = false;
+
function formatTime(ms) {
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;
- 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() {
const now = Date.now();
- document.querySelectorAll(".activity-timestamp").forEach((el) => {
+ for (const el of document.querySelectorAll(".activity-timestamp")) {
const start = Number(el.dataset.start);
- if (!start) return;
+ if (!start) continue;
const elapsed = now - start;
- const mins = Math.floor(elapsed / 60000);
- const secs = Math.floor((elapsed % 60000) / 1000);
const display = el.querySelector(".elapsed");
- if (display)
- display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`;
- });
+ if (display) display.textContent = `(${formatVerbose(elapsed)} ago)`;
+ }
- document.querySelectorAll(".progress-bar").forEach((bar) => {
+ for (const bar of document.querySelectorAll(".progress-bar")) {
const start = Number(bar.dataset.start);
const end = Number(bar.dataset.end);
- if (!start || !end || end <= start) return;
+ if (!start || !end || end <= start) continue;
const duration = end - start;
- const elapsed = now - start;
+ const elapsed = Math.min(now - start, duration);
const progress = Math.min(
100,
Math.max(0, Math.floor((elapsed / duration) * 100)),
@@ -38,14 +62,15 @@ function updateElapsedAndProgress() {
const fill = bar.querySelector(".progress-fill");
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 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 currentEl = label.querySelector(".progress-current");
@@ -54,7 +79,7 @@ function updateElapsedAndProgress() {
const id = `${start}-${end}`;
const last = activityProgressMap.get(id);
- if (last !== undefined && last === current) {
+ if (isPaused || (last !== undefined && last === current)) {
label.classList.add("paused");
} else {
label.classList.remove("paused");
@@ -62,49 +87,209 @@ function updateElapsedAndProgress() {
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);
- });
+ }
}
-updateElapsedAndProgress();
-setInterval(updateElapsedAndProgress, 1000);
+function loadEffectScript(effect) {
+ const existing = document.querySelector(`script[data-effect="${effect}"]`);
+ if (existing) return;
-const head = document.querySelector("head");
-let userId = head?.dataset.userId;
-let instanceUri = head?.dataset.instanceUri;
+ const script = document.createElement("script");
+ script.src = `/public/js/${effect}.js`;
+ script.dataset.effect = effect;
+ document.head.appendChild(script);
+}
-if (userId && instanceUri) {
- if (!instanceUri.startsWith("http")) {
- instanceUri = `https://${instanceUri}`;
+function resolveActivityImage(img, applicationId) {
+ if (!img) return null;
+
+ if (img.startsWith("mp:external/")) {
+ return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`;
}
- const wsUri = instanceUri
- .replace(/^http:/, "ws:")
- .replace(/^https:/, "wss:")
- .replace(/\/$/, "");
+ if (img.includes("/https/")) {
+ const clean = img.split("/https/")[1];
+ return clean ? `https://${clean}` : null;
+ }
- const socket = new WebSocket(`${wsUri}/socket`);
+ if (img.startsWith("spotify:")) {
+ return `https://i.scdn.co/image/${img.split(":")[1]}`;
+ }
- socket.addEventListener("open", () => {
- socket.send(
- JSON.stringify({
- op: 2,
- d: {
- subscribe_to_id: userId,
- },
- }),
- );
- });
+ if (img.startsWith("twitch:")) {
+ const username = img.split(":")[1];
+ return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`;
+ }
- socket.addEventListener("message", (event) => {
- const payload = JSON.parse(event.data);
+ return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`;
+}
- if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") {
- updatePresence(payload.d);
- updateElapsedAndProgress();
+async function populateReviews(userId) {
+ if (!reviewURL || !userId || isLoadingReviews || !hasMoreReviews) return;
+ const reviewSection = document.getElementById("reviews-section");
+ const reviewList = reviewSection?.querySelector(".reviews-list");
+ if (!reviewList) return;
+
+ isLoadingReviews = true;
+
+ try {
+ const url = `${reviewURL}/users/${userId}/reviews?flags=2&offset=${currentReviewOffset}`;
+ const res = await fetch(url);
+ const data = await res.json();
+
+ if (!data.success || !Array.isArray(data.reviews)) {
+ if (currentReviewOffset === 0) reviewSection.classList.add("hidden");
+ isLoadingReviews = false;
+ return;
}
- });
+
+ const reviewsHTML = data.reviews
+ .map((review) => {
+ const sender = review.sender;
+ const username = sender.username;
+ const avatar = sender.profilePhoto;
+ let comment = review.comment;
+
+ comment = comment.replace(
+ /<(a?):\w+:(\d+)>/g,
+ (_, animated, id) =>
+ ``,
+ );
+
+ const timestamp = review.timestamp
+ ? new Date(review.timestamp * 1000).toLocaleString(undefined, {
+ hour12: false,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : "N/A";
+
+ const badges = (sender.badges || [])
+ .map(
+ (b) =>
+ `
`,
+ )
+ .join("");
+
+ return `
+
+ ${emojiHTML}${custom.state ? `${custom.state}` : ""} +
+ `; + + if (customStatus) { + customStatus.outerHTML = html; + } else { + userInfoInner.insertAdjacentHTML("beforeend", html); + } + } else if (customStatus) { + customStatus.remove(); + } +} + +async function getAllNoAsset() { + const noAssetImages = document.querySelectorAll( + "img.activity-image.no-asset", + ); + + for (const img of noAssetImages) { + const name = img.dataset.name; + if (!name) continue; + + try { + const res = await fetch(`/api/art/${encodeURIComponent(name)}`); + if (!res.ok) continue; + + const { icon } = await res.json(); + if (icon) { + img.src = icon; + img.classList.remove("no-asset"); + img.parentElement.classList.remove("no-asset"); + } + } catch (err) { + console.warn(`Failed to fetch fallback icon for "${name}"`, err); + } + } +} + +function updateClanBadge(data) { + const userInfoInner = document.querySelector(".user-info-inner"); + if (!userInfoInner) return; + + const clan = data?.discord_user?.primary_guild; + if (!clan || !clan.tag || !clan.identity_guild_id || !clan.badge) return; + + const existing = userInfoInner.querySelector(".clan-badge"); + if (existing) existing.remove(); + + const wrapper = document.createElement("div"); + wrapper.className = "clan-badge"; + + const img = document.createElement("img"); + img.src = `https://cdn.discordapp.com/clan-badges/${clan.identity_guild_id}/${clan.badge}`; + img.alt = "Clan Badge"; + + const span = document.createElement("span"); + span.className = "clan-name"; + span.textContent = clan.tag; + + wrapper.appendChild(img); + wrapper.appendChild(span); + + const usernameEl = userInfoInner.querySelector(".username"); + if (usernameEl) { + usernameEl.insertAdjacentElement("afterend", wrapper); + } else { + userInfoInner.appendChild(wrapper); + } +} + +if (instanceUri) { + if (!instanceUri.startsWith("http")) { + instanceUri = `https://${instanceUri}`; + } + + const wsUri = instanceUri + .replace(/^http:/, "ws:") + .replace(/^https:/, "wss:") + .replace(/\/$/, ""); + + socket = new WebSocket(`${wsUri}/socket`); +} + +if (badgeURL && badgeURL !== "null" && userId) { + if (!badgeURL.startsWith("http")) { + badgeURL = `https://${badgeURL}`; + } + + if (!badgeURL.endsWith("/")) { + badgeURL += "/"; + } +} + +if (userId && instanceUri) { + let heartbeatInterval = null; + + socket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data); + + if (payload.error || payload.success === false) { + const loadingOverlay = document.getElementById("loading-overlay"); + if (loadingOverlay) { + loadingOverlay.innerHTML = ` + + `; + loadingOverlay.style.opacity = "1"; + } + return; + } + + if (payload.op === 1 && payload.d?.heartbeat_interval) { + heartbeatInterval = setInterval(() => { + socket.send(JSON.stringify({ op: 3 })); + }, payload.d.heartbeat_interval); + + socket.send( + JSON.stringify({ + op: 2, + d: { + subscribe_to_id: userId, + }, + }), + ); + } + + if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { + updatePresence(payload); + requestAnimationFrame(updateElapsedAndProgress); + } + }); + + socket.addEventListener("close", () => { + if (heartbeatInterval) clearInterval(heartbeatInterval); + }); +} + +updateElapsedAndProgress(); +setInterval(updateElapsedAndProgress, 1000); diff --git a/public/js/rain.js b/public/js/rain.js new file mode 100644 index 0000000..1e51d6e --- /dev/null +++ b/public/js/rain.js @@ -0,0 +1,88 @@ +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 oldest = raindrops.shift(); + rainContainer.removeChild(oldest); + } + + const raindrop = document.createElement("div"); + raindrop.classList.add("raindrop"); + raindrop.style.position = "absolute"; + const height = Math.random() * 10 + 10; + raindrop.style.width = "2px"; + raindrop.style.height = `${height}px`; + raindrop.style.background = getRaindropColor(); + raindrop.style.borderRadius = "1px"; + raindrop.style.opacity = Math.random() * 0.5 + 0.3; + + raindrop.x = Math.random() * window.innerWidth; + raindrop.y = -height; + raindrop.speed = Math.random() * 6 + 4; + raindrop.directionX = (Math.random() - 0.5) * 0.2; + raindrop.directionY = Math.random() * 0.5 + 0.8; + + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; + + raindrops.push(raindrop); + rainContainer.appendChild(raindrop); +}; + +setInterval(createRaindrop, 50); + +function updateRaindrops() { + raindrops.forEach((raindrop, index) => { + const height = Number.parseFloat(raindrop.style.height); + + raindrop.x += raindrop.directionX * raindrop.speed; + raindrop.y += raindrop.directionY * raindrop.speed; + + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; + + if (raindrop.y > window.innerHeight) { + rainContainer.removeChild(raindrop); + raindrops.splice(index, 1); + return; + } + + if ( + raindrop.x > window.innerWidth || + raindrop.y > window.innerHeight || + raindrop.x < 0 + ) { + raindrop.x = Math.random() * window.innerWidth; + raindrop.y = -height; + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; + } + }); + + requestAnimationFrame(updateRaindrops); +} + +updateRaindrops(); diff --git a/public/js/snow.js b/public/js/snow.js new file mode 100644 index 0000000..4d3e755 --- /dev/null +++ b/public/js/snow.js @@ -0,0 +1,95 @@ +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"; + const size = Math.random() * 3 + 2; + snowflake.style.width = `${size}px`; + snowflake.style.height = `${size}px`; + snowflake.style.background = "white"; + snowflake.style.borderRadius = "50%"; + snowflake.style.opacity = Math.random(); + + snowflake.x = Math.random() * window.innerWidth; + snowflake.y = -size; + snowflake.speed = Math.random() * 3 + 2; + snowflake.directionX = (Math.random() - 0.5) * 0.5; + snowflake.directionY = Math.random() * 0.5 + 0.5; + + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; + + snowflakes.push(snowflake); + snowContainer.appendChild(snowflake); +}; + +setInterval(createSnowflake, 80); + +function updateSnowflakes() { + snowflakes.forEach((snowflake, index) => { + const size = Number.parseFloat(snowflake.style.width); + const centerX = snowflake.x + size / 2; + const centerY = snowflake.y + size / 2; + + const dx = centerX - mouse.x; + const dy = centerY - mouse.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 30) { + snowflake.directionX += (dx / distance) * 0.02; + snowflake.directionY += (dy / distance) * 0.02; + } else { + snowflake.directionX += (Math.random() - 0.5) * 0.01; + snowflake.directionY += (Math.random() - 0.5) * 0.01; + } + + snowflake.x += snowflake.directionX * snowflake.speed; + snowflake.y += snowflake.directionY * snowflake.speed; + + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; + + if (snowflake.y > window.innerHeight) { + snowContainer.removeChild(snowflake); + snowflakes.splice(index, 1); + return; + } + + if ( + snowflake.x > window.innerWidth || + snowflake.y > window.innerHeight || + snowflake.x < 0 + ) { + snowflake.x = Math.random() * window.innerWidth; + snowflake.y = -size; + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; + } + }); + + requestAnimationFrame(updateSnowflakes); +} + +updateSnowflakes(); diff --git a/public/js/stars.js b/public/js/stars.js new file mode 100644 index 0000000..6fff4eb --- /dev/null +++ b/public/js/stars.js @@ -0,0 +1,63 @@ +const container = document.createElement("div"); +container.style.position = "fixed"; +container.style.top = "0"; +container.style.left = "0"; +container.style.width = "100vw"; +container.style.height = "100vh"; +container.style.pointerEvents = "none"; +container.style.overflow = "hidden"; +container.style.zIndex = "9999"; +document.body.appendChild(container); + +for (let i = 0; i < 60; i++) { + const star = document.createElement("div"); + star.className = "star"; + const size = Math.random() * 2 + 1; + star.style.width = `${size}px`; + star.style.height = `${size}px`; + star.style.opacity = Math.random(); + star.style.top = `${Math.random() * 100}vh`; + star.style.left = `${Math.random() * 100}vw`; + star.style.animationDuration = `${Math.random() * 3 + 2}s`; + container.appendChild(star); +} + +function createShootingStar() { + const star = document.createElement("div"); + star.className = "shooting-star"; + + star.x = Math.random() * window.innerWidth * 0.8; + star.y = Math.random() * window.innerHeight * 0.3; + const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; + const speed = 10; + const totalFrames = 60; + let frame = 0; + + const deg = angle * (180 / Math.PI); + star.style.left = `${star.x}px`; + star.style.top = `${star.y}px`; + star.style.transform = `rotate(${deg}deg)`; + + container.appendChild(star); + + function animate() { + star.x += Math.cos(angle) * speed; + star.y += Math.sin(angle) * speed; + star.style.left = `${star.x}px`; + star.style.top = `${star.y}px`; + star.style.opacity = `${1 - frame / totalFrames}`; + + frame++; + if (frame < totalFrames) { + requestAnimationFrame(animate); + } else if (star.parentNode === container) { + container.removeChild(star); + } + } + + animate(); +} + +setInterval(() => { + if (Math.random() < 0.3) createShootingStar(); +}, 1000); diff --git a/src/helpers/char.ts b/src/helpers/char.ts deleted file mode 100644 index 6ecab40..0000000 --- a/src/helpers/char.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function timestampToReadable(timestamp?: number): string { - const date: Date = - timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (isNaN(date.getTime())) return "Invalid Date"; - return date.toISOString().replace("T", " ").replace("Z", ""); -} diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts deleted file mode 100644 index 6b03dd0..0000000 --- a/src/helpers/ejs.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { renderFile } from "ejs"; -import { resolve } from "path"; - -export async function renderEjsTemplate( - viewName: string | string[], - data: EjsTemplateData, - headers?: Record