diff --git a/.env.example b/.env.example index a10f7a4..d2b3d15 100644 --- a/.env.example +++ b/.env.example @@ -2,24 +2,6 @@ HOST=0.0.0.0 PORT=8080 -REDIS_URL=redis://dragonfly:6379 -REDIS_TTL=3600 # seconds - # this is only the default value if non is give in /id LANYARD_USER_ID=id-here LANYARD_INSTANCE=https://lanyard.rest - -# Required if you want to enable badges -BADGE_API_URL=http://localhost:8081 - -# Required if you want to enable reviews from reviewdb -REVIEW_DB=true - -#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 deleted file mode 100644 index 15c990c..0000000 --- a/.forgejo/workflows/biomejs.yml +++ /dev/null @@ -1,24 +0,0 @@ -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 16498de..d0ef245 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ /node_modules bun.lock .env -logs/ -.vscode/ -robots.txt -public/custom +logs/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..25117cc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "github-enterprise.uri": "https://git.creations.works" +} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2438f47..0000000 --- a/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -# use the official Bun image -# see all versions at https://hub.docker.com/r/oven/bun/tags -FROM oven/bun:latest AS base -WORKDIR /usr/src/app - -FROM base AS install -RUN mkdir -p /temp/dev -COPY package.json bun.lock /temp/dev/ -RUN cd /temp/dev && bun install --frozen-lockfile - -# install with --production (exclude devDependencies) -RUN mkdir -p /temp/prod -COPY package.json bun.lock /temp/prod/ -RUN cd /temp/prod && bun install --frozen-lockfile --production - -# copy node_modules from temp directory -# then copy all (non-ignored) project files into the image -FROM base AS prerelease -COPY --from=install /temp/dev/node_modules node_modules -COPY . . - -# [optional] tests & build -ENV NODE_ENV=production - -# copy production dependencies and source code into final image -FROM base AS release -COPY --from=install /temp/prod/node_modules node_modules -COPY --from=prerelease /usr/src/app/src ./src -COPY --from=prerelease /usr/src/app/public ./public -COPY --from=prerelease /usr/src/app/package.json . -COPY --from=prerelease /usr/src/app/tsconfig.json . -COPY --from=prerelease /usr/src/app/config ./config -COPY --from=prerelease /usr/src/app/types ./types - -RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs - -USER bun -ENTRYPOINT [ "bun", "run", "start" ] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d93a942..0000000 --- a/LICENSE +++ /dev/null @@ -1,28 +0,0 @@ -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 f4b9394..48d4c50 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,3 @@ -# Discord Profile Page +# Cool little discord profile page -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) +E diff --git a/biome.json b/biome.json deleted file mode 100644 index 46ee8c9..0000000 --- a/biome.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$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 deleted file mode 100644 index f2c01ff..0000000 --- a/compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - profile-page: - container_name: profilePage - build: - context: . - restart: unless-stopped - ports: - - "${PORT:-6600}:${PORT:-6600}" - env_file: - - .env - networks: - - profilePage-network - - dragonfly: - image: 'docker.dragonflydb.io/dragonflydb/dragonfly' - restart: unless-stopped - ulimits: - memlock: -1 - volumes: - - dragonflydata:/data - networks: - - profilePage-network - -volumes: - dragonflydata: - -networks: - profilePage-network: - driver: bridge diff --git a/config/environment.ts b/config/environment.ts index 2c89b09..c584b45 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,66 +1,12 @@ -import { echo } from "@atums/echo"; - -const environment: Environment = { - port: Number.parseInt(process.env.PORT || "8080", 10), +export const environment: Environment = { + port: 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"), }; -const redisTtl: number = process.env.REDIS_TTL - ? Number.parseInt(process.env.REDIS_TTL, 10) - : 60 * 60 * 1; // 1 hour - -const lanyardConfig: LanyardConfig = { +export const lanyardConfig: LanyardConfig = { userId: process.env.LANYARD_USER_ID || "", - instance: process.env.LANYARD_INSTANCE || "", -}; - -const reviewDb = { - enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1", - url: "https://manti.vendicated.dev/api/reviewdb", -}; - -const 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, + instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", }; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d43df76 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,132 @@ +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 deleted file mode 100644 index 521b3bc..0000000 --- a/logger.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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 5654ef1..7d18b41 100644 --- a/package.json +++ b/package.json @@ -5,20 +5,31 @@ "scripts": { "start": "bun run src/index.ts", "dev": "bun run --hot src/index.ts --dev", - "lint": "bunx biome ci . --verbose", - "lint:fix": "bunx biome check --fix", + "lint": "eslint", + "lint:fix": "bun lint --fix", "cleanup": "rm -rf logs node_modules bun.lockdb" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", + "@eslint/js": "^9.24.0", "@types/bun": "^1.2.8", - "globals": "^16.0.0" + "@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" }, "peerDependencies": { "typescript": "^5.8.3" }, "dependencies": { - "@atums/echo": "^1.0.3", + "ejs": "^3.1.10", + "node-vibrant": "^4.0.3", "marked": "^15.0.7" } } diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico index 72ecc34..69ec50d 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 deleted file mode 100644 index d65b7ec..0000000 Binary files a/public/assets/favicon.png and /dev/null differ diff --git a/public/assets/forgejo_logo.svg b/public/assets/forgejo_logo.svg deleted file mode 100644 index be0b3ce..0000000 --- a/public/assets/forgejo_logo.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - Forgejo logo - Caesar Schinas - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/css/error.css b/public/css/error.css new file mode 100644 index 0000000..a8e591d --- /dev/null +++ b/public/css/error.css @@ -0,0 +1,25 @@ +body { + display: flex; + justify-content: center; + align-items: center; + min-height: 90vh; + background: #0e0e10; + color: #fff; + font-family: system-ui, sans-serif; +} +.error-container { + text-align: center; + padding: 2rem; + background: #1a1a1d; + border-radius: 12px; + box-shadow: 0 0 20px rgba(0,0,0,0.3); +} +.error-title { + font-size: 2rem; + margin-bottom: 1rem; + color: #ff4e4e; +} +.error-message { + font-size: 1.2rem; + opacity: 0.8; +} diff --git a/public/css/index.css b/public/css/index.css index 8960831..da470b9 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,83 +1,7 @@ -.raindrop { - position: absolute; - background-color: white; - border-radius: 50%; - pointer-events: none; - z-index: 1; -} - -.star, -.snowflake { - position: absolute; - background-color: white; - border-radius: 50%; - pointer-events: none; - z-index: 1; -} - -.star { - animation: twinkle ease-in-out infinite alternate; -} - -.shooting-star { - position: absolute; - background: linear-gradient(90deg, white, transparent); - width: 100px; - height: 2px; - opacity: 0.8; - border-radius: 2px; - transform-origin: left center; -} - -@keyframes twinkle { - from { - opacity: 0.3; - transform: scale(1); - } - to { - opacity: 1; - transform: scale(1.2); - } -} - -#loading-overlay { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 99999; - - transition: opacity 0.5s ease; -} - -.loading-spinner { - width: 50px; - height: 50px; - border: 5px solid var(--border-color); - border-top: 5px solid var(--progress-fill); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -/* actual styles below */ body { font-family: system-ui, sans-serif; - background-color: var(--background); - color: var(--text-color); + background-color: #0e0e10; + color: #ffffff; margin: 0; padding: 2rem; display: flex; @@ -85,59 +9,19 @@ 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: 700px; + max-width: 600px; width: 100%; } .avatar-status-wrapper { display: flex; align-items: center; - gap: 2rem; - - width: fit-content; - max-width: 700px; + gap: 1.5rem; } .avatar-wrapper { @@ -152,35 +36,12 @@ main { border-radius: 50%; } -.badges { - max-width: 700px; - box-sizing: border-box; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - gap: 0.3rem; - flex-wrap: wrap; - margin-top: 0.5rem; - padding: 0.5rem; - background-color: var(--card-bg); - border-radius: 10px; - border: 1px solid var(--border-color); - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); -} - -.badge { - width: 26px; - height: 26px; - border-radius: 50%; -} - .decoration { position: absolute; - top: -13px; - left: -16px; - width: 160px; - height: 160px; + top: -18px; + left: -18px; + width: 164px; + height: 164px; pointer-events: none; } @@ -191,58 +52,35 @@ main { width: 24px; height: 24px; border-radius: 50%; - border: 4px solid var(--background); + border: 4px solid #0e0e10; display: flex; align-items: center; justify-content: center; } .status-indicator.online { - background-color: var(--status-online); + background-color: #23a55a; } .status-indicator.idle { - background-color: var(--status-idle); + background-color: #f0b232; } .status-indicator.dnd { - background-color: var(--status-dnd); + background-color: #f23f43; } .status-indicator.offline { - background-color: var(--status-offline); -} - -.status-indicator.streaming { - background-color: var(--status-streaming); + background-color: #747f8d; } .platform-icon.mobile-only { position: absolute; - bottom: 0; + bottom: 4px; right: 4px; width: 30px; height: 30px; pointer-events: none; - background-color: var(--background); - padding: 0.3rem 0.1rem; - border-radius: 8px; -} - -.platform-icon.mobile-only.dnd { - fill: var(--status-dnd); -} -.platform-icon.mobile-only.idle { - fill: var(--status-idle); -} -.platform-icon.mobile-only.online { - fill: var(--status-online); -} -.platform-icon.mobile-only.offline { - fill: var(--status-offline); -} -.platform-icon.mobile-only.streaming { - fill: var(--status-streaming); } .user-info { @@ -250,65 +88,15 @@ main { flex-direction: column; } -.user-info-inner { - display: flex; - flex-direction: row; - align-items: center; - text-align: center; - gap: 0.5rem; -} -.user-info-inner a { - text-decoration: none; - color: var(--link-color); -} - -.user-info-inner h1 { - font-size: 2rem; - margin: 0; -} - -.clan-badge { - width: fit-content; - height: fit-content; - border-radius: 8px; - background-color: var(--card-bg); - - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: 0.3rem; - padding: 0.3rem 0.5rem; - - text-align: center; - align-items: center; - justify-content: center; -} - -.clan-badge img { - width: 20px; - height: 20px; - margin: 0; - padding: 0; -} - -.clan-badge span { - font-size: 0.9rem; - color: var(--text-color); - margin: 0; - - font-weight: 600; -} - h1 { font-size: 2.5rem; margin: 0; - color: var(--link-color); + color: #00b0f4; } .custom-status { font-size: 1.2rem; - color: var(--text-subtle); + color: #bbb; margin-top: 0.25rem; word-break: break-word; overflow-wrap: anywhere; @@ -319,6 +107,7 @@ h1 { flex-wrap: wrap; } + .custom-status .custom-emoji { width: 20px; height: 20px; @@ -336,26 +125,7 @@ ul { list-style: none; padding: 0; width: 100%; - max-width: 700px; -} - -.activities-section { - display: flex; - flex-direction: column; - gap: 1rem; - width: 100%; - max-width: 700px; - box-sizing: border-box; - padding: 0; - margin: 0; -} - -.activities-section .activity-block-header { - margin: 1rem 0 .5rem; - font-size: 2rem; - font-weight: 600; - - text-align: center; + max-width: 600px; } .activities { @@ -363,8 +133,7 @@ ul { flex-direction: column; gap: 1rem; width: 100%; - max-width: 700px; - box-sizing: border-box; + max-width: 600px; padding: 0; margin: 0; } @@ -373,57 +142,19 @@ ul { display: flex; flex-direction: row; gap: 1rem; - 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); - } + background: #1a1a1d; + padding: 1rem; + border-radius: 6px; + box-shadow: 0 0 0 1px #2e2e30; + transition: background 0.2s ease; + align-items: flex-start; } -.activity-wrapper { - display: flex; - flex-direction: column; - width: 100%; +.activity:hover { + background: #2a2a2d; } -.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 { +.activity-art { width: 80px; height: 80px; border-radius: 6px; @@ -434,15 +165,7 @@ 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; } @@ -452,91 +175,64 @@ ul { align-items: flex-start; } -.activity-bottom { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - .activity-name { - font-weight: 600; - font-size: 1rem; - color: var(--text-color); + font-weight: bold; + font-size: 1.1rem; + color: #ffffff; } .activity-detail { - font-size: 0.875rem; - color: var(--text-secondary); + font-size: 0.95rem; + color: #ccc; } .activity-timestamp { - font-size: 0.75rem; - color: var(--text-secondary); + font-size: 0.8rem; + color: #777; text-align: right; - margin-left: auto; - white-space: nowrap; } .progress-bar { - height: 4px; - background-color: var(--border-color); - border-radius: 2px; - margin-top: 1rem; + height: 6px; + background-color: #333; + border-radius: 3px; overflow: hidden; + width: 100%; + margin-top: 0.5rem; } .progress-fill { - background-color: var(--progress-fill); - transition: width 0.4s ease; height: 100%; -} - -.progress-bar, -.progress-time-labels { - width: 100%; + background-color: #00b0f4; + transition: width 0.5s ease; } .progress-time-labels { display: flex; justify-content: space-between; font-size: 0.75rem; - color: var(--text-muted); + color: #888; 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: var(--status-idle); + color: #f0b232; } .activity-buttons { display: flex; + flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; - justify-content: flex-end; } .activity-button { - background-color: var(--progress-fill); + background-color: #5865f2; color: white; border: none; border-radius: 3px; @@ -546,17 +242,18 @@ ul { text-decoration: none; transition: background-color 0.2s ease; display: inline-block; +} - &:hover { - background-color: var(--button-hover-bg); - text-decoration: none; - } +.activity-button:hover { + background-color: #4752c4; + text-decoration: none; +} - &:disabled { - background-color: var(--button-disabled-bg); - cursor: not-allowed; - opacity: 0.8; - } +.activity-button.disabled { + background-color: #4e5058; + cursor: default; + pointer-events: none; + opacity: 0.8; } @media (max-width: 600px) { @@ -573,16 +270,6 @@ ul { .user-card { width: 100%; align-items: center; - margin-top: 2rem; - } - - .badges { - max-width: 100%; - border-radius: 0; - border: none; - background-color: transparent; - margin-top: 0; - box-shadow: none; } .avatar-status-wrapper { @@ -593,13 +280,6 @@ ul { width: 100%; } - .activity-image-wrapper { - max-width: 100%; - max-height: 100%; - width: 100%; - height: 100%; - } - .avatar-wrapper { width: 96px; height: 96px; @@ -654,31 +334,21 @@ ul { align-items: center; text-align: center; padding: 1rem; - border-radius: 0; + border-radius:0; } - .activity-image { + .activity-art { width: 100%; - max-width: 100%; + max-width: 300px; 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; @@ -715,16 +385,12 @@ ul { /* readme :p */ .readme { - max-width: fit-content; - min-width: 700px; - overflow: hidden; + max-width: 600px; width: 100%; - background: var(--readme-bg); + background: #1a1a1d; padding: 1.5rem; border-radius: 8px; - border: 1px solid var(--border-color); - - margin-top: 1rem; + box-shadow: 0 0 0 1px #2e2e30; box-sizing: border-box; overflow: hidden; @@ -733,13 +399,13 @@ ul { .readme h2 { margin-top: 0; - color: var(--link-color); + color: #00b0f4; } .markdown-body { font-size: 1rem; line-height: 1.6; - color: var(--text-color); + color: #ddd; } .markdown-body h1, @@ -748,7 +414,7 @@ ul { .markdown-body h4, .markdown-body h5, .markdown-body h6 { - color: var(--text-color); + color: #ffffff; margin-top: 1.25rem; margin-bottom: 0.5rem; } @@ -758,7 +424,7 @@ ul { } .markdown-body a { - color: var(--link-color); + color: #00b0f4; text-decoration: none; } @@ -767,7 +433,7 @@ ul { } .markdown-body code { - background: var(--border-color); + background: #2e2e30; padding: 0.2em 0.4em; border-radius: 4px; font-family: monospace; @@ -775,7 +441,7 @@ ul { } .markdown-body pre { - background: var(--border-color); + background: #2e2e30; padding: 1rem; border-radius: 6px; overflow-x: auto; @@ -790,9 +456,9 @@ ul { } .markdown-body blockquote { - border-left: 4px solid var(--link-color); + border-left: 4px solid #00b0f4; padding-left: 1rem; - color: var(--blockquote-color); + color: #aaa; margin: 1rem 0; } @@ -802,8 +468,7 @@ ul { @media (max-width: 600px) { .readme { - max-width: 100%; - min-width: 100%; + width: 100%; padding: 1rem; margin-top: 1rem; @@ -814,218 +479,3 @@ ul { font-size: 0.95rem; } } - -/* reviews */ -.reviews { - width: 100%; - max-width: 700px; - margin-top: 2rem; - display: flex; - flex-direction: column; - gap: 1rem; - background-color: var(--card-bg); - padding: 1rem; - border-radius: 10px; - border: 1px solid var(--border-color); - box-sizing: border-box; -} - -.reviews h2 { - margin: 0 0 1rem; - font-size: 2rem; - font-weight: 600; - text-align: center; -} - -.reviews-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.review { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 1rem; - padding: 0.75rem 1rem; - border-radius: 8px; - border: 1px solid var(--border-color); - transition: background-color 0.3s ease; -} - -.review:hover { - background-color: var(--card-hover-bg); -} - -.review-avatar { - width: 44px; - height: 44px; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; -} - -.review-body { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.review-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -.review-header-inner { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -.review-username { - font-weight: 600; - color: var(--text-color); -} - -.review-timestamp { - font-size: 0.8rem; - color: var(--text-muted); -} - -.review-content { - color: var(--text-secondary); - font-size: 0.95rem; - word-break: break-word; - white-space: pre-wrap; -} - -.review-badges { - display: flex; - gap: 0.3rem; - flex-wrap: wrap; -} - -.emoji { - width: 20px; - height: 20px; - vertical-align: middle; - margin: 0 2px; - display: inline-block; - transition: transform 0.3s ease; -} - -.emoji:hover { - transform: scale(1.2); -} - -.review-content img.emoji { - vertical-align: middle; -} - -@media (max-width: 600px) { - .reviews { - max-width: 100%; - padding: 1rem; - border-radius: 0; - border: none; - background-color: transparent; - } - - .reviews h2 { - font-size: 1.4rem; - text-align: center; - margin-bottom: 1rem; - } - - .reviews-list { - gap: 0.75rem; - } - - .review { - flex-direction: column; - align-items: center; - text-align: center; - padding: 1rem; - border-radius: 0; - } - - .review-avatar { - width: 64px; - height: 64px; - } - - .review-body { - width: 100%; - align-items: center; - } - - .review-header { - flex-direction: column; - align-items: center; - gap: 0.25rem; - } - - .review-username { - font-size: 1rem; - } - - .review-timestamp { - font-size: 0.75rem; - } - - .review-content { - font-size: 0.9rem; - } - - .review-badges { - justify-content: center; - } - - .emoji { - width: 16px; - height: 16px; - } -} - -/* 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 deleted file mode 100644 index dec65f3..0000000 --- a/public/css/root.css +++ /dev/null @@ -1,29 +0,0 @@ -:root { - --background: #0e0e10; - --readme-bg: #1a1a1d; - --card-bg: #1e1f22; - --card-hover-bg: #2a2a2d; - --border-color: #2e2e30; - - --text-color: #ffffff; - --text-subtle: #bbb; - --text-secondary: #b5bac1; - --text-muted: #888; - --link-color: #00b0f4; - - --button-bg: #5865f2; - --button-hover-bg: #4752c4; - --button-disabled-bg: #2d2e31; - - --progress-bg: #f23f43; - --progress-fill: #5865f2; - - --status-online: #23a55a; - --status-idle: #f0b232; - --status-dnd: #e03e3e; - --status-offline: #747f8d; - --status-streaming: #b700ff; - - --blockquote-color: #aaa; - --code-bg: #2e2e30; -} diff --git a/public/js/index.js b/public/js/index.js index 68e05b6..6feec74 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1,60 +1,36 @@ -const head = document.querySelector("head"); -const userId = head?.dataset.userId; +/* eslint-disable indent */ + 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 hours = Math.floor(totalSecs / 3600); - const mins = Math.floor((totalSecs % 3600) / 60); + const mins = Math.floor(totalSecs / 60); const secs = totalSecs % 60; - - 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`; + return `${mins}:${secs.toString().padStart(2, "0")}`; } function updateElapsedAndProgress() { const now = Date.now(); - for (const el of document.querySelectorAll(".activity-timestamp")) { + document.querySelectorAll(".activity-timestamp").forEach((el) => { const start = Number(el.dataset.start); - if (!start) continue; + if (!start) return; 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 = `(${formatVerbose(elapsed)} ago)`; - } + if (display) + display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`; + }); - for (const bar of document.querySelectorAll(".progress-bar")) { + document.querySelectorAll(".progress-bar").forEach((bar) => { const start = Number(bar.dataset.start); const end = Number(bar.dataset.end); - if (!start || !end || end <= start) continue; + if (!start || !end || end <= start) return; const duration = end - start; - const elapsed = Math.min(now - start, duration); + const elapsed = now - start; const progress = Math.min( 100, Math.max(0, Math.floor((elapsed / duration) * 100)), @@ -62,15 +38,14 @@ function updateElapsedAndProgress() { const fill = bar.querySelector(".progress-fill"); if (fill) fill.style.width = `${progress}%`; - } + }); - for (const label of document.querySelectorAll(".progress-time-labels")) { + document.querySelectorAll(".progress-time-labels").forEach((label) => { const start = Number(label.dataset.start); const end = Number(label.dataset.end); - if (!start || !end || end <= start) continue; + if (!start || !end || end <= start) return; - const isPaused = now > end; - const current = isPaused ? end - start : Math.max(0, now - start); + const current = Math.max(0, now - start); const total = end - start; const currentEl = label.querySelector(".progress-current"); @@ -79,7 +54,7 @@ function updateElapsedAndProgress() { const id = `${start}-${end}`; const last = activityProgressMap.get(id); - if (isPaused || (last !== undefined && last === current)) { + if (last !== undefined && last === current) { label.classList.add("paused"); } else { label.classList.remove("paused"); @@ -87,209 +62,49 @@ function updateElapsedAndProgress() { activityProgressMap.set(id, current); - if (currentEl) { - currentEl.textContent = isPaused - ? `Paused at ${formatTime(current)}` - : formatTime(current); - } + if (currentEl) currentEl.textContent = formatTime(current); if (totalEl) totalEl.textContent = formatTime(total); - } + }); } -function loadEffectScript(effect) { - const existing = document.querySelector(`script[data-effect="${effect}"]`); - if (existing) return; +updateElapsedAndProgress(); +setInterval(updateElapsedAndProgress, 1000); - const script = document.createElement("script"); - script.src = `/public/js/${effect}.js`; - script.dataset.effect = effect; - document.head.appendChild(script); -} +const head = document.querySelector("head"); +let userId = head?.dataset.userId; +let instanceUri = head?.dataset.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)}`; +if (userId && instanceUri) { + if (!instanceUri.startsWith("http")) { + instanceUri = `https://${instanceUri}`; } - if (img.includes("/https/")) { - const clean = img.split("/https/")[1]; - return clean ? `https://${clean}` : null; - } + const wsUri = instanceUri + .replace(/^http:/, "ws:") + .replace(/^https:/, "wss:") + .replace(/\/$/, ""); - if (img.startsWith("spotify:")) { - return `https://i.scdn.co/image/${img.split(":")[1]}`; - } + const socket = new WebSocket(`${wsUri}/socket`); - if (img.startsWith("twitch:")) { - const username = img.split(":")[1]; - return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`; - } + socket.addEventListener("open", () => { + socket.send( + JSON.stringify({ + op: 2, + d: { + subscribe_to_id: userId, + }, + }), + ); + }); - return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; -} + socket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data); -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; + if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { + updatePresence(payload.d); + updateElapsedAndProgress(); } - - 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) => - `emoji`, - ); - - const timestamp = review.timestamp - ? new Date(review.timestamp * 1000).toLocaleString(undefined, { - hour12: false, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - : "N/A"; - - const badges = (sender.badges || []) - .map( - (b) => - `${b.name}`, - ) - .join(""); - - return ` -
  • - ${username}'s avatar -
    -
    -
    - ${username} - ${badges} -
    - ${timestamp} -
    -
    ${comment}
    -
    -
  • - `; - }) - .join(""); - - if (currentReviewOffset === 0) reviewList.innerHTML = reviewsHTML; - else reviewList.insertAdjacentHTML("beforeend", reviewsHTML); - - if (data.reviews.length > 0 && reviewsHTML) { - reviewSection.classList.remove("hidden"); - } - - hasMoreReviews = data.hasNextPage; - isLoadingReviews = false; - } catch (err) { - console.error("Failed to fetch reviews", err); - isLoadingReviews = false; - } -} - -function populateTimezone(userId, format = "24h") { - if (!userId || !timezoneApiUrl || timezoneLoaded) return; - - let currentTimezone = null; - - async function fetchTimezone() { - try { - const res = await fetch( - `${timezoneApiUrl}/get?id=${encodeURIComponent(userId)}`, - ); - if (!res.ok) throw new Error("Failed to fetch timezone"); - - const json = await res.json(); - if (!json || typeof json.timezone !== "string") return; - - currentTimezone = json.timezone; - updateTime(); - timezoneLoaded = true; - } catch (err) { - console.error("Failed to populate timezone", err); - } - } - - function updateTime() { - if (!currentTimezone) return; - - const timezoneEl = document.querySelector(".timezone-value"); - const timezoneWrapper = document.getElementById("timezone-wrapper"); - if (!timezoneEl || !timezoneWrapper) return; - - const now = new Date(); - - const time24 = now.toLocaleTimeString("en-GB", { - timeZone: currentTimezone, - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - - const time12 = now.toLocaleTimeString("en-US", { - timeZone: currentTimezone, - hour12: true, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - - timezoneEl.textContent = format === "24h" ? time24 : time12; - timezoneEl.title = `${format === "12h" ? time24 : time12} (${currentTimezone})`; - - timezoneWrapper.classList.remove("hidden"); - } - - fetchTimezone(); - setInterval(updateTime, 1000); -} - -function setupReviewScrollObserver(userId) { - const sentinel = document.createElement("div"); - sentinel.className = "review-scroll-sentinel"; - document.querySelector(".reviews").appendChild(sentinel); - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasMoreReviews && !isLoadingReviews) { - currentReviewOffset += reviewsPerPage; - populateReviews(userId); - } - }, - { - rootMargin: "200px", - threshold: 0, - }, - ); - - observer.observe(sentinel); + }); } function buildActivityHTML(activity) { @@ -303,362 +118,90 @@ function buildActivityHTML(activity) { ? Math.min(100, Math.floor((elapsed / total) * 100)) : null; + const img = activity.assets?.large_image; let art = null; - let smallArt = null; - - if (activity.assets) { - art = resolveActivityImage( - activity.assets.large_image, - activity.application_id, - ); - smallArt = resolveActivityImage( - activity.assets.small_image, - activity.application_id, - ); + if (img?.includes("https")) { + const clean = img.split("/https/")[1]; + if (clean) art = `https://${clean}`; + } else if (img?.startsWith("spotify:")) { + art = `https://i.scdn.co/image/${img.split(":")[1]}`; + } else if (img) { + art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; } - const activityTypeMap = { - 0: "Playing", - 1: "Streaming", - 2: "Listening to", - 3: "Watching", - 4: "Custom Status", - 5: "Competing", - }; - - const activityType = activityTypeMap[activity.type] - ? `${activityTypeMap[activity.type]}${activity.type === 2 ? ` ${activity.name}` : ""}` - : "Playing"; - const activityTimestamp = - start && progress === null - ? `
    - Since: ${new Date(start).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} -
    ` + !total && start + ? ` +
    + + Since: ${new Date(start).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + +
    ` : ""; - const buttons = (activity.buttons || []) - .map((button, index) => { - const label = typeof button === "string" ? button : button.label; - let url = null; - if (typeof button === "object" && button.url) { - url = button.url; - } else if (index === 0 && activity.url) { - url = activity.url; - } - return url - ? `${label}` - : null; - }) - .filter(Boolean); - - if (!buttons.length && activity.name === "Twitch" && activity.url) { - buttons.push( - `Watch on Twitch`, - ); - } - - if (activity.name === "Spotify" && activity.sync_id) { - buttons.push( - `Listen on Spotify`, - ); - } - - const activityButtons = buttons.length - ? `
    ${buttons.join("")}
    ` - : ""; - const progressBar = progress !== null - ? `
    + ? ` +
    ${formatTime(elapsed)} ${formatTime(total)} -
    ` +
    + ` : ""; - 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; - - const activityArt = `
    - - ${``} -
    `; + const activityButtons = activity.buttons && activity.buttons.length > 0 + ? `
    + ${activity.buttons.map((button, index) => { + const buttonLabel = typeof button === 'string' ? button : button.label; + let buttonUrl = null; + if (typeof button === 'object' && button.url) { + buttonUrl = button.url; + } + else if (index === 0 && activity.url) { + buttonUrl = activity.url; + } + if (buttonUrl) { + return `${buttonLabel}`; + } else { + return `${buttonLabel}`; + } + }).join('')} +
    ` + : ''; return `
  • -
    -
    - ${activityType} + ${art ? `Art` : ""} +
    +
    + ${activity.name} ${activityTimestamp}
    -
    - ${activityArt} -
    -
    -
    -
    - ${primaryLine} -
    - ${secondaryLine ? `
    ${secondaryLine}
    ` : ""} - ${tertiaryLine ? `
    ${tertiaryLine}
    ` : ""} -
    -
    - ${activityButtons} -
    -
    -
    -
    + ${activity.details ? `
    ${activity.details}
    ` : ""} + ${activity.state ? `
    ${activity.state}
    ` : ""} + ${activityButtons} ${progressBar}
  • `; } -async function loadBadges(userId, options = {}) { - const { - services = [], - seperated = false, - cache = true, - targetId = "badges", - serviceOrder = [], - } = options; - - const params = new URLSearchParams(); - if (services.length) params.set("services", services.join(",")); - if (seperated) params.set("seperated", "true"); - if (!cache) params.set("cache", "false"); - - const url = `${badgeURL}${userId}?${params.toString()}`; - const target = document.getElementById(targetId); - if (!target) return; - - target.classList.add("hidden"); - - try { - const res = await fetch(url); - const json = await res.json(); - - if ( - !res.ok || - !json.badges || - Object.values(json.badges).every( - (arr) => !Array.isArray(arr) || arr.length === 0, - ) - ) { - target.textContent = "Failed to load badges."; - return; - } - - target.innerHTML = ""; - - const badgesByService = json.badges; - const renderedServices = new Set(); - - const renderBadges = (badges) => { - for (const badge of badges) { - const img = document.createElement("img"); - img.src = badge.badge; - img.alt = badge.tooltip; - img.title = badge.tooltip; - img.tooltip = badge.tooltip; - img.className = "badge"; - target.appendChild(img); - } - }; - - for (const serviceName of serviceOrder) { - const badges = badgesByService[serviceName]; - if (Array.isArray(badges) && badges.length) { - renderBadges(badges); - renderedServices.add(serviceName); - } - } - - for (const [serviceName, badges] of Object.entries(badgesByService)) { - if (renderedServices.has(serviceName)) continue; - if (Array.isArray(badges) && badges.length) { - renderBadges(badges); - } - } - - target.classList.remove("hidden"); - } catch (err) { - console.error(err); - target.innerHTML = ""; - target.classList.add("hidden"); - } -} - -async function populateReadme(data) { - if (readmeLoaded) return; - - const readmeSection = document.querySelector(".readme"); - const kv = data.kv || {}; - - if (readmeSection && kv.readme) { - const url = kv.readme; - try { - const res = await fetch(`/api/readme?url=${encodeURIComponent(url)}`); - if (!res.ok) throw new Error("Failed to fetch readme"); - - const text = await res.text(); - - readmeSection.innerHTML = `
    ${text}
    `; - readmeSection.classList.remove("hidden"); - readmeLoaded = true; - } catch (err) { - console.error("Failed to load README", err); - readmeSection.classList.add("hidden"); - } - } else if (readmeSection) { - readmeSection.classList.add("hidden"); - } -} - -async function updatePresence(initialData) { - if ( - !initialData || - typeof initialData !== "object" || - initialData.success === false || - initialData.error - ) { - const loadingOverlay = document.getElementById("loading-overlay"); - if (loadingOverlay) { - loadingOverlay.innerHTML = ` -
    -

    ${initialData?.error?.message || "Failed to load presence data."}

    -
    - `; - loadingOverlay.style.opacity = "1"; - } - return; - } - - const data = - initialData?.d && Object.keys(initialData.d).length > 0 - ? initialData.d - : initialData; - - const kv = data.kv || {}; - - if (kv.optout === "true") { - const loadingOverlay = document.getElementById("loading-overlay"); - if (loadingOverlay) { - loadingOverlay.innerHTML = ` -
    -

    This user has opted out of sharing their presence.

    -
    - `; - loadingOverlay.style.opacity = "1"; - } - return; - } - - const cssLink = kv.css; - if (cssLink && !cssLoaded) { - try { - const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`); - if (!res.ok) throw new Error("Failed to fetch CSS"); - - const cssText = await res.text(); - const style = document.createElement("style"); - style.textContent = cssText; - document.head.appendChild(style); - cssLoaded = true; - } catch (err) { - console.error("Failed to load CSS", err); - } - } - - if (!badgesLoaded && data?.kv && data.kv.badges !== "false") { - loadBadges(userId, { - services: [], - seperated: true, - cache: true, - targetId: "badges", - serviceOrder: ["discord", "equicord", "reviewdb", "vencord"], - }); - badgesLoaded = true; - } - +function updatePresence(data) { const avatarWrapper = document.querySelector(".avatar-wrapper"); - const avatarImg = avatarWrapper?.querySelector(".avatar"); - const decorationImg = avatarWrapper?.querySelector(".decoration"); - const usernameEl = document.querySelector(".username"); + const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); + const mobileIcon = avatarWrapper?.querySelector( + ".platform-icon.mobile-only", + ); - if (!data.discord_user) { - const loadingOverlay = document.getElementById("loading-overlay"); - if (loadingOverlay) { - loadingOverlay.innerHTML = ` -
    -

    Failed to load user data.

    -
    - `; - loadingOverlay.style.opacity = "1"; - avatarWrapper.classList.add("hidden"); - avatarImg.classList.add("hidden"); - usernameEl.classList.add("hidden"); - document.title = "Error"; - } - return; - } - - if (avatarImg && data.discord_user?.avatar) { - const newAvatarUrl = `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}`; - avatarImg.src = newAvatarUrl; - avatarImg.classList.remove("hidden"); - - const siteIcon = document.getElementById("site-icon"); - - if (siteIcon) { - siteIcon.href = newAvatarUrl; - } - } - - if ( - decorationImg && - data.discord_user?.avatar_decoration_data && - data.discord_user.avatar_decoration_data.asset - ) { - const newDecorationUrl = `https://cdn.discordapp.com/avatar-decoration-presets/${data.discord_user.avatar_decoration_data.asset}`; - decorationImg.src = newDecorationUrl; - decorationImg.classList.remove("hidden"); - } else if (decorationImg) { - decorationImg.src = ""; - decorationImg.classList.add("hidden"); - } - - if (usernameEl) { - const username = - data.discord_user.global_name || data.discord_user.username; - usernameEl.innerHTML = `${username}`; - document.title = username; - } - - updateClanBadge(data); - if (kv.reviews !== "false") { - populateReviews(userId); - setupReviewScrollObserver(userId); - } - - if (kv.timezone !== "false" && userId && timezoneApiUrl) { - populateTimezone(userId, kv.timezone_12 === "true" ? "12h" : "24h"); - } + const userInfo = document.querySelector(".user-info"); + const customStatus = userInfo?.querySelector(".custom-status"); const platform = { mobile: data.active_on_discord_mobile, @@ -666,257 +209,42 @@ async function updatePresence(initialData) { 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) { + statusIndicator.className = `status-indicator ${data.discord_status}`; } - for (const el of avatarWrapper.querySelectorAll(".platform-icon")) { - const platformType = ["mobile-only", "desktop-only", "web-only"].find( - (type) => el.classList.contains(type), - ); - - if (!platformType) continue; - - const active = - (platformType === "mobile-only" && platform.mobile) || - (platformType === "desktop-only" && platform.desktop) || - (platformType === "web-only" && platform.web); - - if (!active) { - el.remove(); - } else { - el.setAttribute("class", `platform-icon ${platformType} ${status}`); - } - } - - if ( - platform.mobile && - !avatarWrapper.querySelector(".platform-icon.mobile-only") - ) { - const mobileIcon = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - mobileIcon.setAttribute("class", `platform-icon mobile-only ${status}`); - mobileIcon.setAttribute("viewBox", "0 0 1000 1500"); - mobileIcon.setAttribute("fill", "#43a25a"); - mobileIcon.setAttribute("aria-label", "Mobile"); - mobileIcon.setAttribute("width", "17"); - mobileIcon.setAttribute("height", "17"); - mobileIcon.innerHTML = ` - + if (platform.mobile && !mobileIcon) { + avatarWrapper.innerHTML += ` + + + `; - avatarWrapper.appendChild(mobileIcon); - } - - const updatedStatusIndicator = - avatarWrapper.querySelector(".status-indicator"); - if (!updatedStatusIndicator) { - const statusDiv = document.createElement("div"); - statusDiv.className = `status-indicator ${status}`; - avatarWrapper.appendChild(statusDiv); - } else { - updatedStatusIndicator.className = `status-indicator ${status}`; + } else if (!platform.mobile && mobileIcon) { + mobileIcon.remove(); + avatarWrapper.innerHTML += `
    `; } const custom = data.activities?.find((a) => a.type === 4); - updateCustomStatus(custom); - - populateReadme(data); - - const filtered = data.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 (customStatus && custom) { + let emojiHTML = ""; + const emoji = custom.emoji; + if (emoji?.id) { + const emojiUrl = `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`; + emojiHTML = `${emoji.name}`; + } else if (emoji?.name) { + emojiHTML = `${emoji.name} `; + } + customStatus.innerHTML = `${emojiHTML}${custom.state}`; + } + const filtered = data.activities?.filter((a) => a.type !== 4); const activityList = document.querySelector(".activities"); - const activitiesTitle = document.querySelector(".activity-block-header"); - if (activityList && activitiesTitle) { + if (activityList) { + activityList.innerHTML = ""; if (filtered?.length) { activityList.innerHTML = filtered.map(buildActivityHTML).join(""); - activitiesTitle.classList.remove("hidden"); - } else { - activityList.innerHTML = ""; - activitiesTitle.classList.add("hidden"); } updateElapsedAndProgress(); - getAllNoAsset(); - } - - if (kv.snow === "true") loadEffectScript("snow"); - if (kv.rain === "true") loadEffectScript("rain"); - if (kv.stars === "true") loadEffectScript("stars"); - - const loadingOverlay = document.getElementById("loading-overlay"); - if (loadingOverlay) { - loadingOverlay.style.opacity = "0"; - setTimeout(() => loadingOverlay.remove(), 500); } } - -function updateCustomStatus(custom) { - const userInfoInner = document.querySelector(".user-info"); - const customStatus = userInfoInner?.querySelector(".custom-status"); - - if (!userInfoInner) return; - - if (custom) { - let emojiHTML = ""; - if (custom.emoji?.id) { - const emojiUrl = `https://cdn.discordapp.com/emojis/${custom.emoji.id}.${custom.emoji.animated ? "gif" : "png"}`; - emojiHTML = `${custom.emoji.name}`; - } else if (custom.emoji?.name) { - emojiHTML = `${custom.emoji.name} `; - } - - const html = ` -

    - ${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 = ` -
    -

    ${payload.error?.message || "An unknown error occurred."}

    -
    - `; - 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 deleted file mode 100644 index 1e51d6e..0000000 --- a/public/js/rain.js +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 4d3e755..0000000 --- a/public/js/snow.js +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 6fff4eb..0000000 --- a/public/js/stars.js +++ /dev/null @@ -1,63 +0,0 @@ -const container = document.createElement("div"); -container.style.position = "fixed"; -container.style.top = "0"; -container.style.left = "0"; -container.style.width = "100vw"; -container.style.height = "100vh"; -container.style.pointerEvents = "none"; -container.style.overflow = "hidden"; -container.style.zIndex = "9999"; -document.body.appendChild(container); - -for (let i = 0; i < 60; i++) { - const star = document.createElement("div"); - star.className = "star"; - const size = Math.random() * 2 + 1; - star.style.width = `${size}px`; - star.style.height = `${size}px`; - star.style.opacity = Math.random(); - star.style.top = `${Math.random() * 100}vh`; - star.style.left = `${Math.random() * 100}vw`; - star.style.animationDuration = `${Math.random() * 3 + 2}s`; - container.appendChild(star); -} - -function createShootingStar() { - const star = document.createElement("div"); - star.className = "shooting-star"; - - star.x = Math.random() * window.innerWidth * 0.8; - star.y = Math.random() * window.innerHeight * 0.3; - const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; - const speed = 10; - const totalFrames = 60; - let frame = 0; - - const deg = angle * (180 / Math.PI); - star.style.left = `${star.x}px`; - star.style.top = `${star.y}px`; - star.style.transform = `rotate(${deg}deg)`; - - container.appendChild(star); - - function animate() { - star.x += Math.cos(angle) * speed; - star.y += Math.sin(angle) * speed; - star.style.left = `${star.x}px`; - star.style.top = `${star.y}px`; - star.style.opacity = `${1 - frame / totalFrames}`; - - frame++; - if (frame < totalFrames) { - requestAnimationFrame(animate); - } else if (star.parentNode === container) { - container.removeChild(star); - } - } - - animate(); -} - -setInterval(() => { - if (Math.random() < 0.3) createShootingStar(); -}, 1000); diff --git a/src/helpers/char.ts b/src/helpers/char.ts new file mode 100644 index 0000000..6ecab40 --- /dev/null +++ b/src/helpers/char.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..6b03dd0 --- /dev/null +++ b/src/helpers/ejs.ts @@ -0,0 +1,26 @@ +import { renderFile } from "ejs"; +import { resolve } from "path"; + +export async function renderEjsTemplate( + viewName: string | string[], + data: EjsTemplateData, + headers?: Record, +): Promise { + let templatePath: string; + + if (Array.isArray(viewName)) { + templatePath = resolve("src", "views", ...viewName); + } else { + templatePath = resolve("src", "views", viewName); + } + + if (!templatePath.endsWith(".ejs")) { + templatePath += ".ejs"; + } + + const html: string = await renderFile(templatePath, data); + + return new Response(html, { + headers: { "Content-Type": "text/html", ...headers }, + }); +} diff --git a/src/helpers/lanyard.ts b/src/helpers/lanyard.ts new file mode 100644 index 0000000..f226f91 --- /dev/null +++ b/src/helpers/lanyard.ts @@ -0,0 +1,92 @@ +import { lanyardConfig } from "@config/environment"; +import { fetch } from "bun"; +import { marked } from "marked"; + +export async function getLanyardData(id?: string): Promise { + let instance: string = lanyardConfig.instance; + + if (instance.endsWith("/")) { + instance = instance.slice(0, -1); + } + + if (!instance.startsWith("http://") && !instance.startsWith("https://")) { + instance = `https://${instance}`; + } + + const url: string = `${instance}/v1/users/${id || lanyardConfig.userId}`; + const res: Response = await fetch(url, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + if (!res.ok) { + return { + success: false, + error: { + code: "API_ERROR", + message: `Lanyard API responded with status ${res.status}`, + }, + }; + } + + const data: LanyardResponse = (await res.json()) as LanyardResponse; + + if (!data.success) { + return { + success: false, + error: { + code: "API_ERROR", + message: "Failed to fetch valid Lanyard data", + }, + }; + } + + return data; +} + +export async function handleReadMe(data: LanyardData): Promise { + const userReadMe: string | null = data.kv?.readme; + + if ( + !userReadMe || + !userReadMe.toLowerCase().endsWith("readme.md") || + !userReadMe.startsWith("http") + ) { + return null; + } + + try { + const res: Response = await fetch(userReadMe, { + headers: { + Accept: "text/markdown", + }, + }); + + const contentType: string = res.headers.get("content-type") || ""; + if ( + !res.ok || + !( + contentType.includes("text/markdown") || + contentType.includes("text/plain") + ) + ) + return null; + + if (res.headers.has("content-length")) { + const size: number = parseInt( + res.headers.get("content-length") || "0", + 10, + ); + if (size > 1024 * 100) return null; + } + + const text: string = await res.text(); + if (!text || text.length < 10) return null; + + return marked.parse(text); + } catch { + return null; + } +} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..331be1d --- /dev/null +++ b/src/helpers/logger.ts @@ -0,0 +1,205 @@ +import { environment } from "@config/environment"; +import { timestampToReadable } from "@helpers/char"; +import type { Stats } from "fs"; +import { + createWriteStream, + existsSync, + mkdirSync, + statSync, + WriteStream, +} from "fs"; +import { EOL } from "os"; +import { basename, join } from "path"; + +class Logger { + private static instance: Logger; + private static log: string = join(__dirname, "../../logs"); + + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + + return Logger.instance; + } + + private writeToLog(logMessage: string): void { + if (environment.development) return; + + const date: Date = new Date(); + const logDir: string = Logger.log; + const logFile: string = join( + logDir, + `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, + ); + + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); + } + + let addSeparator: boolean = false; + + if (existsSync(logFile)) { + const fileStats: Stats = statSync(logFile); + if (fileStats.size > 0) { + const lastModified: Date = new Date(fileStats.mtime); + if ( + lastModified.getFullYear() === date.getFullYear() && + lastModified.getMonth() === date.getMonth() && + lastModified.getDate() === date.getDate() && + lastModified.getHours() !== date.getHours() + ) { + addSeparator = true; + } + } + } + + const stream: WriteStream = createWriteStream(logFile, { flags: "a" }); + + if (addSeparator) { + stream.write(`${EOL}${date.toISOString()}${EOL}`); + } + + stream.write(`${logMessage}${EOL}`); + stream.close(); + } + + private extractFileName(stack: string): string { + const stackLines: string[] = stack.split("\n"); + let callerFile: string = ""; + + for (let i: number = 2; i < stackLines.length; i++) { + const line: string = stackLines[i].trim(); + if (line && !line.includes("Logger.") && line.includes("(")) { + callerFile = line.split("(")[1]?.split(")")[0] || ""; + break; + } + } + + return basename(callerFile); + } + + private getCallerInfo(stack: unknown): { + filename: string; + timestamp: string; + } { + const filename: string = + typeof stack === "string" ? this.extractFileName(stack) : "unknown"; + + const readableTimestamp: string = timestampToReadable(); + + return { filename, timestamp: readableTimestamp }; + } + + public info(message: string | string[], breakLine: boolean = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[INFO]", color: "32" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public warn(message: string | string[], breakLine: boolean = false): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[WARN]", color: "33" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public error( + message: string | Error | (string | Error)[], + breakLine: boolean = false, + ): void { + const stack: string = new Error().stack || ""; + const { filename, timestamp } = this.getCallerInfo(stack); + + const messages: (string | Error)[] = Array.isArray(message) + ? message + : [message]; + const joinedMessage: string = messages + .map((msg: string | Error): string => + typeof msg === "string" ? msg : msg.message, + ) + .join(" "); + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: "[ERROR]", color: "31" }, + filename: { value: `(${filename})`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public custom( + bracketMessage: string, + bracketMessage2: string, + message: string | string[], + color: string, + breakLine: boolean = false, + ): void { + const stack: string = new Error().stack || ""; + const { timestamp } = this.getCallerInfo(stack); + + const joinedMessage: string = Array.isArray(message) + ? message.join(" ") + : message; + + const logMessageParts: ILogMessageParts = { + readableTimestamp: { value: timestamp, color: "90" }, + level: { value: bracketMessage, color }, + filename: { value: `${bracketMessage2}`, color: "36" }, + message: { value: joinedMessage, color: "0" }, + }; + + this.writeToLog( + `${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`, + ); + this.writeConsoleMessageColored(logMessageParts, breakLine); + } + + public space(): void { + console.log(); + } + + private writeConsoleMessageColored( + logMessageParts: ILogMessageParts, + breakLine: boolean = false, + ): void { + const logMessage: string = Object.keys(logMessageParts) + .map((key: string) => { + const part: ILogMessagePart = logMessageParts[key]; + return `\x1b[${part.color}m${part.value}\x1b[0m`; + }) + .join(" "); + console.log(logMessage + (breakLine ? EOL : "")); + } +} + +const logger: Logger = Logger.getInstance(); +export { logger }; diff --git a/src/index.ts b/src/index.ts index 71c6418..6d2801d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,16 @@ +import { logger } from "@helpers/logger"; + import { serverHandler } from "@/server"; -import { echo } from "@atums/echo"; -import { verifyRequiredVariables } from "@config/environment"; async function main(): Promise { - verifyRequiredVariables(); - serverHandler.initialize(); + try { + serverHandler.initialize(); + } catch (error) { + throw error; + } } main().catch((error: Error) => { - echo.error({ message: "Error initializing the server", error }); + logger.error(["Error initializing the server:", error]); process.exit(1); }); - -if (process.env.IN_PTERODACTYL === "true") { - console.log("Server Started"); -} diff --git a/src/routes/[id].ts b/src/routes/[id].ts index 06c32e7..25d8f30 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -1,12 +1,6 @@ -import { resolve } from "node:path"; -import { - badgeApi, - lanyardConfig, - plausibleScript, - reviewDb, - timezoneAPIUrl, -} from "@config/environment"; -import { file } from "bun"; +import { lanyardConfig } from "@config/environment"; +import { renderEjsTemplate } from "@helpers/ejs"; +import { getLanyardData, handleReadMe } from "@helpers/lanyard"; const routeDef: RouteDef = { method: "GET", @@ -16,40 +10,44 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { id } = request.params; - const instance = lanyardConfig.instance - .replace(/^https?:\/\//, "") - .replace(/\/$/, ""); + const data: LanyardResponse = await getLanyardData(id); - const path = resolve("src", "views", "index.html"); - const bunFile = file(path); + if (!data.success) { + return await renderEjsTemplate("error", { + message: data.error.message, + }); + } - const html = new HTMLRewriter() - .on("head", { - element(head) { - head.setAttribute("data-user-id", id || lanyardConfig.userId); - head.setAttribute("data-instance-uri", instance); - head.setAttribute("data-badge-url", badgeApi || ""); + let instance: string = lanyardConfig.instance; - if (reviewDb.enabled) { - head.setAttribute("data-review-db", reviewDb.url); - } + if (instance.endsWith("/")) { + instance = instance.slice(0, -1); + } - if (timezoneAPIUrl) { - head.setAttribute("data-timezone-api", timezoneAPIUrl); - } + if (instance.startsWith("http://") || instance.startsWith("https://")) { + instance = instance.slice(instance.indexOf("://") + 3); + } - if (plausibleScript) { - head.append(plausibleScript, { html: true }); - } - }, - }) - .transform(await bunFile.text()); + const presence: LanyardData = data.data; + const readme: string | Promise | null = + await handleReadMe(presence); - return new Response(html, { - headers: { - "Content-Type": "text/html", + const ejsTemplateData: EjsTemplateData = { + title: `${presence.discord_user.username || "Unknown"}`, + username: presence.discord_user.username, + status: presence.discord_status, + activities: presence.activities, + user: presence.discord_user, + platform: { + desktop: presence.active_on_discord_desktop, + mobile: presence.active_on_discord_mobile, + web: presence.active_on_discord_web, }, - }); + instance, + readme, + }; + + return await renderEjsTemplate("index", ejsTemplateData); } export { handler, routeDef }; diff --git a/src/routes/api/art[game].ts b/src/routes/api/art[game].ts deleted file mode 100644 index c91b7b4..0000000 --- a/src/routes/api/art[game].ts +++ /dev/null @@ -1,93 +0,0 @@ -import { redisTtl, steamGridDbKey } from "@config/environment"; -import { redis } from "bun"; - -const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "application/json", - log: false, -}; - -async function fetchSteamGridIcon(gameName: string): Promise { - const cacheKey = `steamgrid:icon:${gameName.toLowerCase()}`; - const cached = await redis.get(cacheKey); - if (cached) return cached; - - const search = await fetch( - `https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameName)}`, - { - headers: { - Authorization: `Bearer ${steamGridDbKey}`, - }, - }, - ); - - if (!search.ok) return null; - - const { data } = await search.json(); - if (!data?.length) return null; - - const gameId = data[0]?.id; - if (!gameId) return null; - - const iconRes = await fetch( - `https://www.steamgriddb.com/api/v2/icons/game/${gameId}`, - { - headers: { - Authorization: `Bearer ${steamGridDbKey}`, - }, - }, - ); - - if (!iconRes.ok) return null; - - const iconData = await iconRes.json(); - const icon = iconData?.data?.[0]?.url ?? null; - - if (icon) { - await redis.set(cacheKey, icon); - await redis.expire(cacheKey, redisTtl); - } - return icon; -} - -async function handler(request: ExtendedRequest): Promise { - if (!steamGridDbKey) { - return Response.json( - { - status: 503, - error: "Route disabled due to missing SteamGridDB key", - }, - { status: 503 }, - ); - } - - const { game } = request.params; - - if (!game || typeof game !== "string" || game.length < 2) { - return Response.json( - { status: 400, error: "Missing or invalid game name" }, - { status: 400 }, - ); - } - - const icon = await fetchSteamGridIcon(game); - - if (!icon) { - return Response.json( - { status: 404, error: "Icon not found" }, - { status: 404 }, - ); - } - - return Response.json( - { - status: 200, - game, - icon, - }, - { status: 200 }, - ); -} - -export { handler, routeDef }; diff --git a/src/routes/api/colors.ts b/src/routes/api/colors.ts new file mode 100644 index 0000000..d8817ca --- /dev/null +++ b/src/routes/api/colors.ts @@ -0,0 +1,69 @@ +import { fetch } from "bun"; +import { Vibrant } from "node-vibrant/node"; + +type Palette = Awaited>; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + const { url } = request.query; + + if (!url) { + return Response.json({ error: "URL is required" }, { status: 400 }); + } + + if (typeof url !== "string" || !url.startsWith("http")) { + 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, { + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + "Cache-Control": "public, max-age=31536000, immutable", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +export { handler, routeDef }; diff --git a/src/routes/api/css.ts b/src/routes/api/css.ts deleted file mode 100644 index 92c4f37..0000000 --- a/src/routes/api/css.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { redisTtl } from "@config/environment"; -import { fetch } from "bun"; -import { redis } from "bun"; - -const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "*/*", - log: false, -}; - -async function fetchAndCacheCss(url: string): Promise { - const cacheKey = `css:${url}`; - const cached = await redis.get(cacheKey); - if (cached) return cached; - - const res = await fetch(url, { - headers: { - Accept: "text/css", - }, - }); - - if (!res.ok) return null; - - if (res.headers.has("content-length")) { - const size = Number.parseInt(res.headers.get("content-length") || "0", 10); - if (size > 1024 * 50) return null; - } - - const text = await res.text(); - if (!text || text.length < 5) return null; - - const sanitized = text - .replace(/[\s\S]*?<\/script>/gi, "") - .replace(/@import\s+url\(['"]?(.*?)['"]?\);?/gi, ""); - - await redis.set(cacheKey, sanitized); - await redis.expire(cacheKey, redisTtl); - - return sanitized; -} - -async function handler(request: ExtendedRequest): Promise { - const { url } = request.query; - - if (!url || !url.startsWith("http") || !/\.css$/i.test(url)) { - return Response.json( - { - success: false, - error: { - code: "INVALID_URL", - message: "Invalid URL provided", - }, - }, - { status: 400 }, - ); - } - - const sanitized = await fetchAndCacheCss(url); - - if (!sanitized) { - return Response.json( - { - success: false, - error: { - code: "FETCH_FAILED", - message: "Failed to fetch or sanitize CSS", - }, - }, - { status: 400 }, - ); - } - - return new Response(sanitized, { - headers: { - "Content-Type": "text/css", - "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - status: 200, - }); -} - -export { handler, routeDef }; diff --git a/src/routes/api/readme.ts b/src/routes/api/readme.ts deleted file mode 100644 index ca94922..0000000 --- a/src/routes/api/readme.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { redisTtl } from "@config/environment"; -import { fetch, redis } from "bun"; -import { marked } from "marked"; - -const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "*/*", - log: false, -}; - -async function addLazyLoading(html: string): Promise { - return new HTMLRewriter() - .on("img", { - element(el) { - el.setAttribute("loading", "lazy"); - }, - }) - .transform(html); -} - -async function sanitizeHtml(html: string): Promise { - return new HTMLRewriter() - .on( - "script, iframe, object, embed, link[rel=import], svg, math, base, meta[http-equiv='refresh']", - { - element(el) { - el.remove(); - }, - }, - ) - .on("*", { - element(el) { - for (const [name, value] of el.attributes) { - const lowerName = name.toLowerCase(); - const lowerValue = value.toLowerCase(); - - if (lowerName.startsWith("on")) { - el.removeAttribute(name); - } - - if ( - (lowerName === "href" || - lowerName === "src" || - lowerName === "action") && - (lowerValue.startsWith("javascript:") || - lowerValue.startsWith("data:")) - ) { - el.removeAttribute(name); - } - } - }, - }) - .on("img", { - element(el) { - el.setAttribute("loading", "lazy"); - }, - }) - .transform(html); -} - -async function fetchAndCacheReadme(url: string): Promise { - const cacheKey = `readme:${url}`; - const cached = await redis.get(cacheKey); - if (cached) return cached; - - const res = await fetch(url, { - headers: { - Accept: "text/markdown", - }, - }); - - if (!res.ok) return null; - - if (res.headers.has("content-length")) { - const size = Number.parseInt(res.headers.get("content-length") || "0", 10); - if (size > 1024 * 100) return null; - } - - const text = await res.text(); - if (!text || text.length < 10) return null; - - const html = /\.(html?|htm)$/i.test(url) ? text : await marked.parse(text); - - const safe = await sanitizeHtml(html); - - await redis.set(cacheKey, safe); - await redis.expire(cacheKey, redisTtl); - - return safe; -} - -async function handler(request: ExtendedRequest): Promise { - const { url } = request.query; - - if ( - !url || - !url.startsWith("http") || - !/\.(md|markdown|txt|html?)$/i.test(url) - ) { - return Response.json( - { - success: false, - error: { - code: "INVALID_URL", - message: "Invalid URL provided", - }, - }, - { status: 400 }, - ); - } - - const safe = await fetchAndCacheReadme(url); - - if (!safe) { - return Response.json( - { - success: false, - error: { - code: "FETCH_FAILED", - message: "Failed to fetch or process file", - }, - }, - { status: 400 }, - ); - } - - return new Response(safe, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - status: 200, - }); -} - -export { handler, routeDef }; diff --git a/src/routes/index.ts b/src/routes/index.ts index e3b78cb..692fc59 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,13 +1,54 @@ -import { handler as idHandler, routeDef as idRouteDef } from "./[id]"; +import { lanyardConfig } from "@config/environment"; +import { renderEjsTemplate } from "@helpers/ejs"; +import { getLanyardData, handleReadMe } from "@helpers/lanyard"; -export const routeDef = { - ...idRouteDef, +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "text/html", }; -export const handler = async ( - request: ExtendedRequest, - body: unknown, - server: BunServer, -) => { - return await idHandler(request); -}; +async function handler(): Promise { + const data: LanyardResponse = await getLanyardData(); + + if (!data.success) { + return await renderEjsTemplate("error", { + message: data.error.message, + }); + } + + let instance: string = lanyardConfig.instance; + + if (instance.endsWith("/")) { + instance = instance.slice(0, -1); + } + + if (instance.startsWith("http://") || instance.startsWith("https://")) { + instance = instance.slice(instance.indexOf("://") + 3); + } + + const presence: LanyardData = data.data; + const readme: string | Promise | null = + await handleReadMe(presence); + + const ejsTemplateData: EjsTemplateData = { + title: + presence.discord_user.global_name || presence.discord_user.username, + username: + presence.discord_user.global_name || presence.discord_user.username, + status: presence.discord_status, + activities: presence.activities, + user: presence.discord_user, + platform: { + desktop: presence.active_on_discord_desktop, + mobile: presence.active_on_discord_mobile, + web: presence.active_on_discord_web, + }, + instance, + readme, + }; + + return await renderEjsTemplate("index", ejsTemplateData); +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index e851a58..13ae2ba 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,12 @@ -import { resolve } from "node:path"; -import { echo } from "@atums/echo"; import { environment } from "@config/environment"; +import { logger } from "@helpers/logger"; import { type BunFile, FileSystemRouter, type MatchedRoute, type Serve, } from "bun"; +import { resolve } from "path"; import { webSocketHandler } from "@/websocket"; @@ -34,23 +34,29 @@ class ServerHandler { open: webSocketHandler.handleOpen.bind(webSocketHandler), message: webSocketHandler.handleMessage.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: string[] = [ + const accessUrls = [ `http://${server.hostname}:${server.port}`, `http://localhost:${server.port}`, `http://127.0.0.1:${server.port}`, ]; - echo.info(`Server running at ${accessUrls[0]}`); - echo.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`); + logger.info(`Server running at ${accessUrls[0]}`, true); + logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); this.logRoutes(); } private logRoutes(): void { - echo.info("Available routes:"); + logger.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -59,19 +65,14 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - echo.info(`Route: ${path}, File: ${filePath}`); + logger.info(`Route: ${path}, File: ${filePath}`); } } - private async serveStaticFile( - request: ExtendedRequest, - pathname: string, - ip: string, - ): Promise { - let filePath: string; - let response: Response; - + private async serveStaticFile(pathname: string): Promise { try { + let filePath: string; + if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -82,49 +83,23 @@ class ServerHandler { if (await file.exists()) { const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = file.type || "application/octet-stream"; + const contentType: string = + file.type || "application/octet-stream"; - response = new Response(fileContent, { + return new Response(fileContent, { headers: { "Content-Type": contentType }, }); } else { - echo.warn(`File not found: ${filePath}`); - response = new Response("Not Found", { status: 404 }); + logger.warn(`File not found: ${filePath}`); + return new Response("Not Found", { status: 404 }); } } catch (error) { - echo.error({ - message: `Error serving static file: ${pathname}`, - error: error as Error, - }); - response = new Response("Internal Server Error", { status: 500 }); + logger.error([ + `Error serving static file: ${pathname}`, + error as Error, + ]); + return new Response("Internal Server Error", { status: 500 }); } - - this.logRequest(request, response, ip); - return response; - } - - private logRequest( - request: ExtendedRequest, - response: Response, - ip: string | undefined, - ): void { - const pathname = new URL(request.url).pathname; - - const ignoredStartsWith: string[] = ["/public"]; - const ignoredPaths: string[] = ["/favicon.ico"]; - - if ( - ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) || - ignoredPaths.includes(pathname) - ) { - return; - } - - echo.custom(`${request.method}`, `${response.status}`, [ - request.url, - `${(performance.now() - request.startPerf).toFixed(2)}ms`, - ip || "unknown", - ]); } private async handleRequest( @@ -134,51 +109,22 @@ class ServerHandler { const extendedRequest: ExtendedRequest = request as ExtendedRequest; extendedRequest.startPerf = performance.now(); - const headers = request.headers; - let ip = server.requestIP(request)?.address; - let response: Response; - - if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { - ip = - headers.get("CF-Connecting-IP")?.trim() || - headers.get("X-Real-IP")?.trim() || - headers.get("X-Forwarded-For")?.split(",")[0].trim() || - "unknown"; - } - const pathname: string = new URL(request.url).pathname; - - const baseDir = resolve("public/custom"); - const customPath = resolve(baseDir, pathname.slice(1)); - - if (!customPath.startsWith(baseDir)) { - return new Response("Forbidden", { status: 403 }); - } - - const customFile = Bun.file(customPath); - if (await customFile.exists()) { - const content = await customFile.arrayBuffer(); - const type = customFile.type || "application/octet-stream"; - response = new Response(content, { - headers: { "Content-Type": type }, - }); - this.logRequest(extendedRequest, response, ip); - return response; - } - if (pathname.startsWith("/public") || pathname === "/favicon.ico") { - return await this.serveStaticFile(extendedRequest, pathname, ip); + return await this.serveStaticFile(pathname); } const match: MatchedRoute | null = this.router.match(request); let requestBody: unknown = {}; + let response: Response; if (match) { const { filePath, params, query } = match; try { const routeModule: RouteModule = await import(filePath); - const contentType: string | null = request.headers.get("Content-Type"); + const contentType: string | null = + request.headers.get("Content-Type"); const actualContentType: string | null = contentType ? contentType.split(";")[0].trim() : null; @@ -205,7 +151,9 @@ class ServerHandler { if ( (Array.isArray(routeModule.routeDef.method) && - !routeModule.routeDef.method.includes(request.method)) || + !routeModule.routeDef.method.includes( + request.method, + )) || (!Array.isArray(routeModule.routeDef.method) && routeModule.routeDef.method !== request.method) ) { @@ -230,7 +178,9 @@ class ServerHandler { if (Array.isArray(expectedContentType)) { matchesAccepts = expectedContentType.includes("*/*") || - expectedContentType.includes(actualContentType || ""); + expectedContentType.includes( + actualContentType || "", + ); } else { matchesAccepts = expectedContentType === "*/*" || @@ -269,10 +219,10 @@ class ServerHandler { } } } catch (error: unknown) { - echo.error({ - message: `Error handling route ${request.url}`, - error: error, - }); + logger.error([ + `Error handling route ${request.url}:`, + error as Error, + ]); response = Response.json( { @@ -294,11 +244,31 @@ class ServerHandler { ); } - this.logRequest(extendedRequest, response, ip); + const headers: Headers = response.headers; + let ip: string | null = server.requestIP(request)?.address || null; + + if (!ip) { + ip = + headers.get("CF-Connecting-IP") || + headers.get("X-Real-IP") || + headers.get("X-Forwarded-For") || + null; + } + + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); + return response; } } - const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/src/views/error.ejs b/src/views/error.ejs new file mode 100644 index 0000000..1a683f1 --- /dev/null +++ b/src/views/error.ejs @@ -0,0 +1,17 @@ + + + + + Error + + + + +
    +
    Something went wrong
    +
    + <%= message || "An unexpected error occurred." %> +
    +
    + + diff --git a/src/views/index.ejs b/src/views/index.ejs new file mode 100644 index 0000000..6790bb5 --- /dev/null +++ b/src/views/index.ejs @@ -0,0 +1,150 @@ + + + + + + + + + + + <%= title %> + + + + + + + +
    +
    +
    + Avatar + <% if (user.avatar_decoration_data) { %> + Decoration + <% } %> + <% if (platform.mobile) { %> + + + + <% } else { %> +
    + <% } %> +
    + +
    +
    + + <% const filtered = activities.filter(a => a.type !== 4); %> + <% if (filtered.length > 0) { %> +

    Activities

    +
      + <% filtered.forEach(activity => { + const start = activity.timestamps?.start; + const end = activity.timestamps?.end; + const now = Date.now(); + const elapsed = start ? now - start : 0; + const total = (start && end) ? end - start : null; + const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null; + + const img = activity.assets?.large_image; + let art = null; + if (img?.includes("https")) { + const clean = img.split("/https/")[1]; + if (clean) art = `https://${clean}`; + } else if (img?.startsWith("spotify:")) { + art = `https://i.scdn.co/image/${img.split(":")[1]}`; + } else if (img) { + art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; + } + %> +
    • + <% if (art) { %> + Art + <% } %> + +
      +
      + <%= activity.name %> + + <% if (start && progress === null) { %> +
      + <% const started = new Date(start); %> + + Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> + +
      + <% } %> +
      + + <% if (activity.details) { %> +
      <%= activity.details %>
      + <% } %> + <% if (activity.state) { %> +
      <%= activity.state %>
      + <% } %> + + <% if (activity.buttons && activity.buttons.length > 0) { %> +
      + <% activity.buttons.forEach((button, index) => { + const buttonLabel = typeof button === 'string' ? button : button.label; + let buttonUrl = null; + if (typeof button === 'object' && button.url) { + buttonUrl = button.url; + } + else if (index === 0 && activity.url) { + buttonUrl = activity.url; + } + %> + <% if (buttonUrl) { %> + <%= buttonLabel %> + <% } else { %> + <%= buttonLabel %> + <% } %> + <% }); %> +
      + <% } %> + + <% if (progress !== null) { %> +
      +
      >
      +
      + + <% if (start && end) { %> +
      + --:-- + <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %> +
      + <% } %> + <% } %> +
      +
    • + <% }) %> +
    + <% } %> + <% if (readme) { %> +

    Readme

    +
    +
    <%- readme %>
    +
    + <% } %> + + diff --git a/src/views/index.html b/src/views/index.html deleted file mode 100644 index 5f71625..0000000 --- a/src/views/index.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - Discord Presence - - - - - - - -
    -
    -
    - -
    - - - - - -
    - -
    -
    -
    -
    - - - -
    - -
    -
    - - - -
    - -
      -
      - - -
      - - - - - - - \ No newline at end of file diff --git a/src/websocket.ts b/src/websocket.ts index 3b00134..ce87fe8 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,27 +1,31 @@ -import { echo } from "@atums/echo"; -import type { ServerWebSocket } from "bun"; +import { logger } from "@helpers/logger"; +import { type ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { - echo.info(`WebSocket received: ${message}`); + logger.info(`WebSocket received: ${message}`); try { ws.send(`You said: ${message}`); } catch (error) { - echo.error({ message: "WebSocket send error", error: error }); + logger.error(["WebSocket send error", error as Error]); } } public handleOpen(ws: ServerWebSocket): void { - echo.info("WebSocket connection opened."); + logger.info("WebSocket connection opened."); try { ws.send("Welcome to the WebSocket server!"); } catch (error) { - echo.error({ message: "WebSocket send error", error: error }); + logger.error(["WebSocket send error", error as Error]); } } - public handleClose(ws: ServerWebSocket, code: number, reason: string): void { - echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + public handleClose( + ws: ServerWebSocket, + code: number, + reason: string, + ): void { + logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } } diff --git a/tsconfig.json b/tsconfig.json index 68a5a97..ac5f2c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,28 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": ["src/*"], - "@config/*": ["config/*"], - "@types/*": ["types/*"], - "@helpers/*": ["src/helpers/*"] + "@/*": [ + "src/*" + ], + "@config/*": [ + "config/*" + ], + "@types/*": [ + "types/*" + ], + "@helpers/*": [ + "src/helpers/*" + ] }, - "typeRoots": ["./src/types", "./node_modules/@types"], + "typeRoots": [ + "./src/types", + "./node_modules/@types" + ], // Enable latest features - "lib": ["ESNext", "DOM"], + "lib": [ + "ESNext", + "DOM" + ], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", @@ -27,7 +41,11 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, }, - "include": ["src", "types", "config"] + "include": [ + "src", + "types", + "config" + ], } diff --git a/types/ejs.d.ts b/types/ejs.d.ts new file mode 100644 index 0000000..486a4a4 --- /dev/null +++ b/types/ejs.d.ts @@ -0,0 +1,3 @@ +interface EjsTemplateData { + [key: string]: string | number | boolean | object | undefined | null; +} diff --git a/types/lanyard.d.ts b/types/lanyard.d.ts new file mode 100644 index 0000000..9c0e136 --- /dev/null +++ b/types/lanyard.d.ts @@ -0,0 +1,72 @@ +interface DiscordUser { + id: string; + username: string; + avatar: string; + discriminator: string; + clan?: string | null; + avatar_decoration_data?: { + sku_id: string; + asset: string; + expires_at: string | null; + }; + bot: boolean; + global_name: string; + primary_guild?: string | null; + collectibles?: { + enabled: boolean; + disabled: boolean; + }; + display_name: string; + public_flags: number; +} + +interface Activity { + id: string; + name: string; + type: number; + state: string; + created_at: number; +} + +interface SpotifyData { + track_id: string; + album_id: string; + album_name: string; + artist_name: string; + track_name: string; +} + +interface Kv { + [key: string]: string; +} + +interface LanyardData { + kv: Kv; + discord_user: DiscordUser; + activities: Activity[]; + discord_status: string; + active_on_discord_web: boolean; + active_on_discord_desktop: boolean; + active_on_discord_mobile: boolean; + listening_to_spotify?: boolean; + spotify?: SpotifyData; + spotify_status: string; + active_on_spotify: boolean; + active_on_xbox: boolean; + active_on_playstation: boolean; +} + +type LanyardSuccess = { + success: true; + data: LanyardData; +}; + +type LanyardError = { + success: false; + error: { + code: string; + message: string; + }; +}; + +type LanyardResponse = LanyardSuccess | LanyardError; diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000..ff6a601 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,9 @@ +type ILogMessagePart = { value: string; color: string }; + +type ILogMessageParts = { + level: ILogMessagePart; + filename: ILogMessagePart; + readableTimestamp: ILogMessagePart; + message: ILogMessagePart; + [key: string]: ILogMessagePart; +}; diff --git a/types/routes.d.ts b/types/routes.d.ts index afb9b2a..9d9d809 100644 --- a/types/routes.d.ts +++ b/types/routes.d.ts @@ -3,7 +3,6 @@ type RouteDef = { accepts: string | null | string[]; returns: string; needsBody?: "multipart" | "json"; - log?: boolean; }; type RouteModule = {