diff --git a/.env.example b/.env.example index d2b3d15..a10f7a4 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,24 @@ HOST=0.0.0.0 PORT=8080 +REDIS_URL=redis://dragonfly:6379 +REDIS_TTL=3600 # seconds + # this is only the default value if non is give in /id LANYARD_USER_ID=id-here LANYARD_INSTANCE=https://lanyard.rest + +# Required if you want to enable badges +BADGE_API_URL=http://localhost:8081 + +# Required if you want to enable reviews from reviewdb +REVIEW_DB=true + +#Timezone api url, aka: https://git.creations.works/creations/timezoneDB +TIMEZONE_API_URL= + +# https://www.steamgriddb.com/api/v2, if you want games to have images +STEAMGRIDDB_API_KEY=steamgrid_api_key + +# https://plausible.io +PLAUSIBLE_SCRIPT_HTML='' diff --git a/.forgejo/workflows/biomejs.yml b/.forgejo/workflows/biomejs.yml new file mode 100644 index 0000000..15c990c --- /dev/null +++ b/.forgejo/workflows/biomejs.yml @@ -0,0 +1,24 @@ +name: Code quality checks + +on: + push: + pull_request: + +jobs: + biome: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + echo "$BUN_INSTALL/bin" >> $GITHUB_PATH + + - name: Install Dependencies + run: bun install + + - name: Run Biome with verbose output + run: bunx biome ci . --verbose diff --git a/.gitignore b/.gitignore index d0ef245..16498de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /node_modules bun.lock .env -logs/ \ No newline at end of file +logs/ +.vscode/ +robots.txt +public/custom diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 25117cc..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "github-enterprise.uri": "https://git.creations.works" -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2438f47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:latest AS base +WORKDIR /usr/src/app + +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lock /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/src ./src +COPY --from=prerelease /usr/src/app/public ./public +COPY --from=prerelease /usr/src/app/package.json . +COPY --from=prerelease /usr/src/app/tsconfig.json . +COPY --from=prerelease /usr/src/app/config ./config +COPY --from=prerelease /usr/src/app/types ./types + +RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs + +USER bun +ENTRYPOINT [ "bun", "run", "start" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d93a942 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, creations.works + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 48d4c50..f4b9394 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,145 @@ -# Cool little discord profile page +# Discord Profile Page -E +A cool little web app that shows your Discord profile, current activity, and more. Built with Bun. + +# Preview +https://creations.works + +--- + +## Requirements + +This project depends on the following services to function properly: + +### 1. Lanyard Backend + +This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance to fetch real-time Discord presence data. +Make sure the Lanyard instance is running and accessible before using this. + +### 2. Redis Instance + +A Redis-compatible key-value store is required to cache third-party data (e.g., SteamGridDB icons). +I recommend [Dragonfly](https://www.dragonflydb.io/), a high-performance drop-in replacement for Redis. + +### 3. Badge API + +A lightweight API to render Discord-style badges. +>Only needed if you want to show badges on profiles: +https://git.creations.works/creations/badgeAPI + +### 4. SteamGridDB + +>Only needed if you want to fetch game icons that Discord doesn’t provide: +https://www.steamgriddb.com/api/v2 + +--- + +## Getting Started + +### 1. Clone & Install + +```bash +git clone https://git.creations.works/creations/profilePage.git +cd profilePage +bun install +``` + +### 2. Configure Environment + +Copy the example environment file and update it: + +```bash +cp .env.example .env +``` + +#### `.env` Variables + +| Variable | Description | +|-----------------------|-----------------------------------------------------------------------------| +| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) | +| `PORT` | Port to run the server on (default: `8080`) | +| `REDIS_URL` | Redis connection string | +| `LANYARD_USER_ID` | Your Discord user ID, for the default page | +| `LANYARD_INSTANCE` | Endpoint of the Lanyard instance | +| `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) | +| `REVIEW_DB` | Enables showing reviews from reviewdb on user pages | +| `TIMEZONE_API_URL` | Enables showing times from [timezoneDB](https://git.creations.works/creations/timezoneDB-rs) | +| `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons | + +#### Optional Lanyard KV Variables (per-user customization) + +These can be defined in Lanyard's KV store to customize the page: + +| Variable | Description | +|-----------|--------------------------------------------------------------------| +| `snow` | Enables snow background (`true` / `false`) | +| `rain` | Enables rain background (`true` / `false`) | +| `stars` | Enables starfield background (`true` / `false`) | +| `badges` | Enables badge fetching (`true` / `false`) | +| `readme` | URL to a README displayed on the profile (`.md` or `.html`) | +| `css` | URL to a css to change styles on the page, no import or require allowed | +| `optout` | Allows users to stop sharing there profile on the website (`true` / `false`) | +| `reviews` | Enables reviews from reviewdb (`true` / `false`) | +| `timezone`| Enables the showing of the current time from the timezone db API (`true` / `false`) | +| `timezone_12` | Sets the timezone to default show 12h format, default is 24h | + +--- + +### 3. Start the Instance + +```bash +bun run start +``` + +--- + +## Optional: Analytics with Plausible + +You can enable [Plausible Analytics](https://plausible.io) tracking by setting a script snippet in your environment. + +### `.env` Variable + +| Variable | Description | +|-------------------------|------------------------------------------------------------------------| +| `PLAUSIBLE_SCRIPT_HTML` | Full `' +``` + +- The script will only be injected if this variable is set. +- Plausible provides the correct script when you add a domain. +- Be sure to wrap it in single quotes (`'`) so it works in `.env`. + +--- + +## Docker Support + +### Build & Start with Docker Compose + +```bash +docker compose up -d --build +``` + +Make sure the `.env` file is configured correctly before starting the container. + +--- + +## Routes + +These are the main public routes exposed by the server: + +| Route | Description | +|---------|-----------------------------------------------------------------------------| +| `/` | Loads the profile page for the default Discord user defined in `.env` (`LANYARD_USER_ID`) | +| `/[id]` | Loads the profile page for a specific Discord user ID passed in the URL | + +> Example: `https://creations.works/209830981060788225` shows the profile of that specific user. + +--- + +## License + +[BSD 3](LICENSE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..46ee8c9 --- /dev/null +++ b/biome.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": true, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "indentStyle": "tab", + "lineEnding": "lf", + "jsxQuoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f2c01ff --- /dev/null +++ b/compose.yml @@ -0,0 +1,29 @@ +services: + profile-page: + container_name: profilePage + build: + context: . + restart: unless-stopped + ports: + - "${PORT:-6600}:${PORT:-6600}" + env_file: + - .env + networks: + - profilePage-network + + dragonfly: + image: 'docker.dragonflydb.io/dragonflydb/dragonfly' + restart: unless-stopped + ulimits: + memlock: -1 + volumes: + - dragonflydata:/data + networks: + - profilePage-network + +volumes: + dragonflydata: + +networks: + profilePage-network: + driver: bridge diff --git a/config/environment.ts b/config/environment.ts index c584b45..2c89b09 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,12 +1,66 @@ -export const environment: Environment = { - port: parseInt(process.env.PORT || "8080", 10), +import { echo } from "@atums/echo"; + +const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), host: process.env.HOST || "0.0.0.0", development: - process.env.NODE_ENV === "development" || - process.argv.includes("--dev"), + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), }; -export const lanyardConfig: LanyardConfig = { +const redisTtl: number = process.env.REDIS_TTL + ? Number.parseInt(process.env.REDIS_TTL, 10) + : 60 * 60 * 1; // 1 hour + +const lanyardConfig: LanyardConfig = { userId: process.env.LANYARD_USER_ID || "", - instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", + instance: process.env.LANYARD_INSTANCE || "", +}; + +const reviewDb = { + enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1", + url: "https://manti.vendicated.dev/api/reviewdb", +}; + +const timezoneAPIUrl: string | null = process.env.TIMEZONE_API_URL || null; + +const badgeApi: string | null = process.env.BADGE_API_URL || null; +const steamGridDbKey: string | undefined = process.env.STEAMGRIDDB_API_KEY; + +const plausibleScript: string | null = + process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null; + +function verifyRequiredVariables(): void { + const requiredVariables = [ + "HOST", + "PORT", + + "LANYARD_USER_ID", + "LANYARD_INSTANCE", + ]; + + let hasError = false; + + for (const key of requiredVariables) { + const value = process.env[key]; + if (value === undefined || value.trim() === "") { + echo.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export { + environment, + lanyardConfig, + redisTtl, + reviewDb, + timezoneAPIUrl, + badgeApi, + steamGridDbKey, + plausibleScript, + verifyRequiredVariables, }; diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index d43df76..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,132 +0,0 @@ -import pluginJs from "@eslint/js"; -import tseslintPlugin from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import prettier from "eslint-plugin-prettier"; -import promisePlugin from "eslint-plugin-promise"; -import simpleImportSort from "eslint-plugin-simple-import-sort"; -import unicorn from "eslint-plugin-unicorn"; -import unusedImports from "eslint-plugin-unused-imports"; -import globals from "globals"; - -/** @type {import('eslint').Linter.FlatConfig[]} */ -export default [ - { - files: ["**/*.{js,mjs,cjs}"], - languageOptions: { - globals: globals.node, - }, - ...pluginJs.configs.recommended, - plugins: { - "simple-import-sort": simpleImportSort, - "unused-imports": unusedImports, - promise: promisePlugin, - prettier: prettier, - unicorn: unicorn, - }, - rules: { - "eol-last": ["error", "always"], - "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }], - "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", - { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - }, - ], - "promise/always-return": "error", - "promise/no-return-wrap": "error", - "promise/param-names": "error", - "promise/catch-or-return": "error", - "promise/no-nesting": "warn", - "promise/no-promise-in-callback": "warn", - "promise/no-callback-in-promise": "warn", - "prettier/prettier": [ - "error", - { - useTabs: true, - tabWidth: 4, - }, - ], - indent: ["error", "tab", { SwitchCase: 1 }], - "unicorn/filename-case": [ - "error", - { - case: "camelCase", - }, - ], - }, - }, - { - files: ["**/*.{ts,tsx}"], - languageOptions: { - parser: tsParser, - globals: globals.node, - }, - plugins: { - "@typescript-eslint": tseslintPlugin, - "simple-import-sort": simpleImportSort, - "unused-imports": unusedImports, - promise: promisePlugin, - prettier: prettier, - unicorn: unicorn, - }, - rules: { - ...tseslintPlugin.configs.recommended.rules, - quotes: ["error", "double"], - "eol-last": ["error", "always"], - "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }], - "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", - { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - }, - ], - "promise/always-return": "error", - "promise/no-return-wrap": "error", - "promise/param-names": "error", - "promise/catch-or-return": "error", - "promise/no-nesting": "warn", - "promise/no-promise-in-callback": "warn", - "promise/no-callback-in-promise": "warn", - "prettier/prettier": [ - "error", - { - useTabs: true, - tabWidth: 4, - }, - ], - indent: ["error", "tab", { SwitchCase: 1 }], - "unicorn/filename-case": [ - "error", - { - case: "camelCase", - }, - ], - "@typescript-eslint/explicit-function-return-type": ["error"], - "@typescript-eslint/explicit-module-boundary-types": ["error"], - "@typescript-eslint/typedef": [ - "error", - { - arrowParameter: true, - variableDeclaration: true, - propertyDeclaration: true, - memberVariableDeclaration: true, - parameter: true, - }, - ], - }, - }, -]; diff --git a/logger.json b/logger.json new file mode 100644 index 0000000..521b3bc --- /dev/null +++ b/logger.json @@ -0,0 +1,39 @@ +{ + "directory": "logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true +} diff --git a/package.json b/package.json index 7d18b41..5654ef1 100644 --- a/package.json +++ b/package.json @@ -5,31 +5,20 @@ "scripts": { "start": "bun run src/index.ts", "dev": "bun run --hot src/index.ts --dev", - "lint": "eslint", - "lint:fix": "bun lint --fix", + "lint": "bunx biome ci . --verbose", + "lint:fix": "bunx biome check --fix", "cleanup": "rm -rf logs node_modules bun.lockdb" }, "devDependencies": { - "@eslint/js": "^9.24.0", + "@biomejs/biome": "^1.9.4", "@types/bun": "^1.2.8", - "@types/ejs": "^3.1.5", - "@typescript-eslint/eslint-plugin": "^8.29.0", - "@typescript-eslint/parser": "^8.29.0", - "eslint": "^9.24.0", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unicorn": "^56.0.1", - "eslint-plugin-unused-imports": "^4.1.4", - "globals": "^15.15.0", - "prettier": "^3.5.3" + "globals": "^16.0.0" }, "peerDependencies": { "typescript": "^5.8.3" }, "dependencies": { - "ejs": "^3.1.10", - "node-vibrant": "^4.0.3", + "@atums/echo": "^1.0.3", "marked": "^15.0.7" } } diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico index 69ec50d..72ecc34 100644 Binary files a/public/assets/favicon.ico and b/public/assets/favicon.ico differ diff --git a/public/assets/favicon.png b/public/assets/favicon.png new file mode 100644 index 0000000..d65b7ec Binary files /dev/null and b/public/assets/favicon.png differ diff --git a/public/assets/forgejo_logo.svg b/public/assets/forgejo_logo.svg new file mode 100644 index 0000000..be0b3ce --- /dev/null +++ b/public/assets/forgejo_logo.svg @@ -0,0 +1,36 @@ + + + + + Forgejo logo + Caesar Schinas + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/css/error.css b/public/css/error.css deleted file mode 100644 index a8e591d..0000000 --- a/public/css/error.css +++ /dev/null @@ -1,25 +0,0 @@ -body { - display: flex; - justify-content: center; - align-items: center; - min-height: 90vh; - background: #0e0e10; - color: #fff; - font-family: system-ui, sans-serif; -} -.error-container { - text-align: center; - padding: 2rem; - background: #1a1a1d; - border-radius: 12px; - box-shadow: 0 0 20px rgba(0,0,0,0.3); -} -.error-title { - font-size: 2rem; - margin-bottom: 1rem; - color: #ff4e4e; -} -.error-message { - font-size: 1.2rem; - opacity: 0.8; -} diff --git a/public/css/index.css b/public/css/index.css index da470b9..8960831 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,7 +1,83 @@ +.raindrop { + position: absolute; + background-color: white; + border-radius: 50%; + pointer-events: none; + z-index: 1; +} + +.star, +.snowflake { + position: absolute; + background-color: white; + border-radius: 50%; + pointer-events: none; + z-index: 1; +} + +.star { + animation: twinkle ease-in-out infinite alternate; +} + +.shooting-star { + position: absolute; + background: linear-gradient(90deg, white, transparent); + width: 100px; + height: 2px; + opacity: 0.8; + border-radius: 2px; + transform-origin: left center; +} + +@keyframes twinkle { + from { + opacity: 0.3; + transform: scale(1); + } + to { + opacity: 1; + transform: scale(1.2); + } +} + +#loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 99999; + + transition: opacity 0.5s ease; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 5px solid var(--border-color); + border-top: 5px solid var(--progress-fill); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* actual styles below */ body { font-family: system-ui, sans-serif; - background-color: #0e0e10; - color: #ffffff; + background-color: var(--background); + color: var(--text-color); margin: 0; padding: 2rem; display: flex; @@ -9,19 +85,59 @@ body { align-items: center; } +main { + width: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.open-source-logo { + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + cursor: pointer; + + position: fixed; + bottom: 1rem; + right: 0.5rem; + z-index: 1000; + + opacity: 0.5; + transition: opacity 0.3s ease; + + &:hover { + opacity: 1 !important; + } +} + +.hidden { + display: none !important; +} + +.activity-header.hidden { + display: none; +} + .user-card { display: flex; flex-direction: column; align-items: center; margin-bottom: 2rem; - max-width: 600px; + max-width: 700px; width: 100%; } .avatar-status-wrapper { display: flex; align-items: center; - gap: 1.5rem; + gap: 2rem; + + width: fit-content; + max-width: 700px; } .avatar-wrapper { @@ -36,12 +152,35 @@ body { border-radius: 50%; } +.badges { + max-width: 700px; + box-sizing: border-box; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 0.3rem; + flex-wrap: wrap; + margin-top: 0.5rem; + padding: 0.5rem; + background-color: var(--card-bg); + border-radius: 10px; + border: 1px solid var(--border-color); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +.badge { + width: 26px; + height: 26px; + border-radius: 50%; +} + .decoration { position: absolute; - top: -18px; - left: -18px; - width: 164px; - height: 164px; + top: -13px; + left: -16px; + width: 160px; + height: 160px; pointer-events: none; } @@ -52,35 +191,58 @@ body { width: 24px; height: 24px; border-radius: 50%; - border: 4px solid #0e0e10; + border: 4px solid var(--background); display: flex; align-items: center; justify-content: center; } .status-indicator.online { - background-color: #23a55a; + background-color: var(--status-online); } .status-indicator.idle { - background-color: #f0b232; + background-color: var(--status-idle); } .status-indicator.dnd { - background-color: #f23f43; + background-color: var(--status-dnd); } .status-indicator.offline { - background-color: #747f8d; + background-color: var(--status-offline); +} + +.status-indicator.streaming { + background-color: var(--status-streaming); } .platform-icon.mobile-only { position: absolute; - bottom: 4px; + bottom: 0; right: 4px; width: 30px; height: 30px; pointer-events: none; + background-color: var(--background); + padding: 0.3rem 0.1rem; + border-radius: 8px; +} + +.platform-icon.mobile-only.dnd { + fill: var(--status-dnd); +} +.platform-icon.mobile-only.idle { + fill: var(--status-idle); +} +.platform-icon.mobile-only.online { + fill: var(--status-online); +} +.platform-icon.mobile-only.offline { + fill: var(--status-offline); +} +.platform-icon.mobile-only.streaming { + fill: var(--status-streaming); } .user-info { @@ -88,15 +250,65 @@ body { flex-direction: column; } +.user-info-inner { + display: flex; + flex-direction: row; + align-items: center; + text-align: center; + gap: 0.5rem; +} +.user-info-inner a { + text-decoration: none; + color: var(--link-color); +} + +.user-info-inner h1 { + font-size: 2rem; + margin: 0; +} + +.clan-badge { + width: fit-content; + height: fit-content; + border-radius: 8px; + background-color: var(--card-bg); + + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.3rem; + padding: 0.3rem 0.5rem; + + text-align: center; + align-items: center; + justify-content: center; +} + +.clan-badge img { + width: 20px; + height: 20px; + margin: 0; + padding: 0; +} + +.clan-badge span { + font-size: 0.9rem; + color: var(--text-color); + margin: 0; + + font-weight: 600; +} + h1 { font-size: 2.5rem; margin: 0; - color: #00b0f4; + color: var(--link-color); } .custom-status { font-size: 1.2rem; - color: #bbb; + color: var(--text-subtle); margin-top: 0.25rem; word-break: break-word; overflow-wrap: anywhere; @@ -107,7 +319,6 @@ h1 { flex-wrap: wrap; } - .custom-status .custom-emoji { width: 20px; height: 20px; @@ -125,7 +336,26 @@ ul { list-style: none; padding: 0; width: 100%; - max-width: 600px; + max-width: 700px; +} + +.activities-section { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + max-width: 700px; + box-sizing: border-box; + padding: 0; + margin: 0; +} + +.activities-section .activity-block-header { + margin: 1rem 0 .5rem; + font-size: 2rem; + font-weight: 600; + + text-align: center; } .activities { @@ -133,7 +363,8 @@ ul { flex-direction: column; gap: 1rem; width: 100%; - max-width: 600px; + max-width: 700px; + box-sizing: border-box; padding: 0; margin: 0; } @@ -142,19 +373,57 @@ ul { display: flex; flex-direction: row; gap: 1rem; - background: #1a1a1d; - padding: 1rem; - border-radius: 6px; - box-shadow: 0 0 0 1px #2e2e30; - transition: background 0.2s ease; - align-items: flex-start; + background-color: var(--card-bg); + padding: 0.75rem 1rem; + border-radius: 10px; + border: 1px solid var(--border-color); + + transition: background-color 0.3s ease; + + &:hover { + background: var(--card-hover-bg); + } } -.activity:hover { - background: #2a2a2d; +.activity-wrapper { + display: flex; + flex-direction: column; + width: 100%; } -.activity-art { +.activity-wrapper-inner { + display: flex; + flex-direction: row; + gap: 1rem; +} + +.activity-image-wrapper { + position: relative; + width: 80px; + height: 80px; +} + +.no-asset { + display: none !important; +} + +.activity-image-small { + width: 25px; + height: 25px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border-color: var(--card-bg); + background-color: var(--card-bg); + border-width: 2px; + border-style: solid; + + position: absolute; + bottom: -6px; + right: -10px; +} + +.activity-image { width: 80px; height: 80px; border-radius: 6px; @@ -165,7 +434,15 @@ ul { .activity-content { display: flex; flex-direction: column; + justify-content: space-between; flex: 1; + gap: 0.5rem; + position: relative; +} + +.activity-top { + display: flex; + flex-direction: column; gap: 0.25rem; } @@ -175,64 +452,91 @@ ul { align-items: flex-start; } +.activity-bottom { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + .activity-name { - font-weight: bold; - font-size: 1.1rem; - color: #ffffff; + font-weight: 600; + font-size: 1rem; + color: var(--text-color); } .activity-detail { - font-size: 0.95rem; - color: #ccc; + font-size: 0.875rem; + color: var(--text-secondary); } .activity-timestamp { - font-size: 0.8rem; - color: #777; + font-size: 0.75rem; + color: var(--text-secondary); text-align: right; + margin-left: auto; + white-space: nowrap; } .progress-bar { - height: 6px; - background-color: #333; - border-radius: 3px; + height: 4px; + background-color: var(--border-color); + border-radius: 2px; + margin-top: 1rem; overflow: hidden; - width: 100%; - margin-top: 0.5rem; } .progress-fill { + background-color: var(--progress-fill); + transition: width 0.4s ease; height: 100%; - background-color: #00b0f4; - transition: width 0.5s ease; +} + +.progress-bar, +.progress-time-labels { + width: 100%; } .progress-time-labels { display: flex; justify-content: space-between; font-size: 0.75rem; - color: #888; + color: var(--text-muted); margin-top: 0.25rem; } +.activity-type-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.activity-type-label { + font-size: 0.75rem; + text-transform: uppercase; + font-weight: 600; + color: var(--blockquote-color); + margin-bottom: 0.5rem; + display: block; +} + .activity-header.no-timestamp { justify-content: flex-start; } .progress-time-labels.paused .progress-current::after { content: " ⏸"; - color: #f0b232; + color: var(--status-idle); } .activity-buttons { display: flex; - flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; + justify-content: flex-end; } .activity-button { - background-color: #5865f2; + background-color: var(--progress-fill); color: white; border: none; border-radius: 3px; @@ -242,18 +546,17 @@ ul { text-decoration: none; transition: background-color 0.2s ease; display: inline-block; -} -.activity-button:hover { - background-color: #4752c4; - text-decoration: none; -} + &:hover { + background-color: var(--button-hover-bg); + text-decoration: none; + } -.activity-button.disabled { - background-color: #4e5058; - cursor: default; - pointer-events: none; - opacity: 0.8; + &:disabled { + background-color: var(--button-disabled-bg); + cursor: not-allowed; + opacity: 0.8; + } } @media (max-width: 600px) { @@ -270,6 +573,16 @@ ul { .user-card { width: 100%; align-items: center; + margin-top: 2rem; + } + + .badges { + max-width: 100%; + border-radius: 0; + border: none; + background-color: transparent; + margin-top: 0; + box-shadow: none; } .avatar-status-wrapper { @@ -280,6 +593,13 @@ ul { width: 100%; } + .activity-image-wrapper { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + } + .avatar-wrapper { width: 96px; height: 96px; @@ -334,21 +654,31 @@ ul { align-items: center; text-align: center; padding: 1rem; - border-radius:0; + border-radius: 0; } - .activity-art { + .activity-image { width: 100%; - max-width: 300px; + max-width: 100%; height: auto; border-radius: 8px; } + .activity-image-small { + width: 40px; + height: 40px; + } + .activity-content { width: 100%; align-items: center; } + .activity-wrapper-inner { + flex-direction: column; + align-items: center; + } + .activity-header { flex-direction: column; align-items: center; @@ -385,12 +715,16 @@ ul { /* readme :p */ .readme { - max-width: 600px; + max-width: fit-content; + min-width: 700px; + overflow: hidden; width: 100%; - background: #1a1a1d; + background: var(--readme-bg); padding: 1.5rem; border-radius: 8px; - box-shadow: 0 0 0 1px #2e2e30; + border: 1px solid var(--border-color); + + margin-top: 1rem; box-sizing: border-box; overflow: hidden; @@ -399,13 +733,13 @@ ul { .readme h2 { margin-top: 0; - color: #00b0f4; + color: var(--link-color); } .markdown-body { font-size: 1rem; line-height: 1.6; - color: #ddd; + color: var(--text-color); } .markdown-body h1, @@ -414,7 +748,7 @@ ul { .markdown-body h4, .markdown-body h5, .markdown-body h6 { - color: #ffffff; + color: var(--text-color); margin-top: 1.25rem; margin-bottom: 0.5rem; } @@ -424,7 +758,7 @@ ul { } .markdown-body a { - color: #00b0f4; + color: var(--link-color); text-decoration: none; } @@ -433,7 +767,7 @@ ul { } .markdown-body code { - background: #2e2e30; + background: var(--border-color); padding: 0.2em 0.4em; border-radius: 4px; font-family: monospace; @@ -441,7 +775,7 @@ ul { } .markdown-body pre { - background: #2e2e30; + background: var(--border-color); padding: 1rem; border-radius: 6px; overflow-x: auto; @@ -456,9 +790,9 @@ ul { } .markdown-body blockquote { - border-left: 4px solid #00b0f4; + border-left: 4px solid var(--link-color); padding-left: 1rem; - color: #aaa; + color: var(--blockquote-color); margin: 1rem 0; } @@ -468,7 +802,8 @@ ul { @media (max-width: 600px) { .readme { - width: 100%; + max-width: 100%; + min-width: 100%; padding: 1rem; margin-top: 1rem; @@ -479,3 +814,218 @@ ul { font-size: 0.95rem; } } + +/* reviews */ +.reviews { + width: 100%; + max-width: 700px; + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; + background-color: var(--card-bg); + padding: 1rem; + border-radius: 10px; + border: 1px solid var(--border-color); + box-sizing: border-box; +} + +.reviews h2 { + margin: 0 0 1rem; + font-size: 2rem; + font-weight: 600; + text-align: center; +} + +.reviews-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.review { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 1rem; + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid var(--border-color); + transition: background-color 0.3s ease; +} + +.review:hover { + background-color: var(--card-hover-bg); +} + +.review-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.review-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.review-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.review-header-inner { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.review-username { + font-weight: 600; + color: var(--text-color); +} + +.review-timestamp { + font-size: 0.8rem; + color: var(--text-muted); +} + +.review-content { + color: var(--text-secondary); + font-size: 0.95rem; + word-break: break-word; + white-space: pre-wrap; +} + +.review-badges { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; +} + +.emoji { + width: 20px; + height: 20px; + vertical-align: middle; + margin: 0 2px; + display: inline-block; + transition: transform 0.3s ease; +} + +.emoji:hover { + transform: scale(1.2); +} + +.review-content img.emoji { + vertical-align: middle; +} + +@media (max-width: 600px) { + .reviews { + max-width: 100%; + padding: 1rem; + border-radius: 0; + border: none; + background-color: transparent; + } + + .reviews h2 { + font-size: 1.4rem; + text-align: center; + margin-bottom: 1rem; + } + + .reviews-list { + gap: 0.75rem; + } + + .review { + flex-direction: column; + align-items: center; + text-align: center; + padding: 1rem; + border-radius: 0; + } + + .review-avatar { + width: 64px; + height: 64px; + } + + .review-body { + width: 100%; + align-items: center; + } + + .review-header { + flex-direction: column; + align-items: center; + gap: 0.25rem; + } + + .review-username { + font-size: 1rem; + } + + .review-timestamp { + font-size: 0.75rem; + } + + .review-content { + font-size: 0.9rem; + } + + .review-badges { + justify-content: center; + } + + .emoji { + width: 16px; + height: 16px; + } +} + +/* timezone display */ + +.timezone-wrapper { + position: fixed; + top: 1rem; + right: 1rem; + background-color: var(--card-bg); + color: var(--text-color); + font-size: 0.9rem; + padding: 0.4rem 0.8rem; + border-radius: 6px; + border: 1px solid var(--border-color); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); + z-index: 100; + user-select: none; + opacity: 0.85; + transition: opacity 0.2s ease; +} + +.timezone-wrapper:hover { + opacity: 1; +} + +.timezone-label { + color: var(--text-muted); + margin-right: 0.4rem; +} + +@media (max-width: 600px) { + .timezone-label { + display: none; + } +} diff --git a/public/css/root.css b/public/css/root.css new file mode 100644 index 0000000..dec65f3 --- /dev/null +++ b/public/css/root.css @@ -0,0 +1,29 @@ +:root { + --background: #0e0e10; + --readme-bg: #1a1a1d; + --card-bg: #1e1f22; + --card-hover-bg: #2a2a2d; + --border-color: #2e2e30; + + --text-color: #ffffff; + --text-subtle: #bbb; + --text-secondary: #b5bac1; + --text-muted: #888; + --link-color: #00b0f4; + + --button-bg: #5865f2; + --button-hover-bg: #4752c4; + --button-disabled-bg: #2d2e31; + + --progress-bg: #f23f43; + --progress-fill: #5865f2; + + --status-online: #23a55a; + --status-idle: #f0b232; + --status-dnd: #e03e3e; + --status-offline: #747f8d; + --status-streaming: #b700ff; + + --blockquote-color: #aaa; + --code-bg: #2e2e30; +} diff --git a/public/js/index.js b/public/js/index.js index 6feec74..68e05b6 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1,36 +1,60 @@ -/* eslint-disable indent */ - +const head = document.querySelector("head"); +const userId = head?.dataset.userId; const activityProgressMap = new Map(); +const reviewURL = head?.dataset.reviewDb; +const timezoneApiUrl = head?.dataset.timezoneApi; +let instanceUri = head?.dataset.instanceUri; +let badgeURL = head?.dataset.badgeUrl; +let socket; + +let badgesLoaded = false; +let readmeLoaded = false; +let cssLoaded = false; +let timezoneLoaded = false; + +const reviewsPerPage = 50; +let currentReviewOffset = 0; +let hasMoreReviews = true; +let isLoadingReviews = false; + function formatTime(ms) { const totalSecs = Math.floor(ms / 1000); - const mins = Math.floor(totalSecs / 60); + const hours = Math.floor(totalSecs / 3600); + const mins = Math.floor((totalSecs % 3600) / 60); const secs = totalSecs % 60; - return `${mins}:${secs.toString().padStart(2, "0")}`; + + return `${String(hours).padStart(1, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; +} + +function formatVerbose(ms) { + const totalSecs = Math.floor(ms / 1000); + const hours = Math.floor(totalSecs / 3600); + const mins = Math.floor((totalSecs % 3600) / 60); + const secs = totalSecs % 60; + + return `${hours}h ${mins}m ${secs}s`; } function updateElapsedAndProgress() { const now = Date.now(); - document.querySelectorAll(".activity-timestamp").forEach((el) => { + for (const el of document.querySelectorAll(".activity-timestamp")) { const start = Number(el.dataset.start); - if (!start) return; + if (!start) continue; const elapsed = now - start; - const mins = Math.floor(elapsed / 60000); - const secs = Math.floor((elapsed % 60000) / 1000); const display = el.querySelector(".elapsed"); - if (display) - display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`; - }); + if (display) display.textContent = `(${formatVerbose(elapsed)} ago)`; + } - document.querySelectorAll(".progress-bar").forEach((bar) => { + for (const bar of document.querySelectorAll(".progress-bar")) { const start = Number(bar.dataset.start); const end = Number(bar.dataset.end); - if (!start || !end || end <= start) return; + if (!start || !end || end <= start) continue; const duration = end - start; - const elapsed = now - start; + const elapsed = Math.min(now - start, duration); const progress = Math.min( 100, Math.max(0, Math.floor((elapsed / duration) * 100)), @@ -38,14 +62,15 @@ function updateElapsedAndProgress() { const fill = bar.querySelector(".progress-fill"); if (fill) fill.style.width = `${progress}%`; - }); + } - document.querySelectorAll(".progress-time-labels").forEach((label) => { + for (const label of document.querySelectorAll(".progress-time-labels")) { const start = Number(label.dataset.start); const end = Number(label.dataset.end); - if (!start || !end || end <= start) return; + if (!start || !end || end <= start) continue; - const current = Math.max(0, now - start); + const isPaused = now > end; + const current = isPaused ? end - start : Math.max(0, now - start); const total = end - start; const currentEl = label.querySelector(".progress-current"); @@ -54,7 +79,7 @@ function updateElapsedAndProgress() { const id = `${start}-${end}`; const last = activityProgressMap.get(id); - if (last !== undefined && last === current) { + if (isPaused || (last !== undefined && last === current)) { label.classList.add("paused"); } else { label.classList.remove("paused"); @@ -62,49 +87,209 @@ function updateElapsedAndProgress() { activityProgressMap.set(id, current); - if (currentEl) currentEl.textContent = formatTime(current); + if (currentEl) { + currentEl.textContent = isPaused + ? `Paused at ${formatTime(current)}` + : formatTime(current); + } if (totalEl) totalEl.textContent = formatTime(total); - }); + } } -updateElapsedAndProgress(); -setInterval(updateElapsedAndProgress, 1000); +function loadEffectScript(effect) { + const existing = document.querySelector(`script[data-effect="${effect}"]`); + if (existing) return; -const head = document.querySelector("head"); -let userId = head?.dataset.userId; -let instanceUri = head?.dataset.instanceUri; + const script = document.createElement("script"); + script.src = `/public/js/${effect}.js`; + script.dataset.effect = effect; + document.head.appendChild(script); +} -if (userId && instanceUri) { - if (!instanceUri.startsWith("http")) { - instanceUri = `https://${instanceUri}`; +function resolveActivityImage(img, applicationId) { + if (!img) return null; + + if (img.startsWith("mp:external/")) { + return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; } - const wsUri = instanceUri - .replace(/^http:/, "ws:") - .replace(/^https:/, "wss:") - .replace(/\/$/, ""); + if (img.includes("/https/")) { + const clean = img.split("/https/")[1]; + return clean ? `https://${clean}` : null; + } - const socket = new WebSocket(`${wsUri}/socket`); + if (img.startsWith("spotify:")) { + return `https://i.scdn.co/image/${img.split(":")[1]}`; + } - socket.addEventListener("open", () => { - socket.send( - JSON.stringify({ - op: 2, - d: { - subscribe_to_id: userId, - }, - }), - ); - }); + if (img.startsWith("twitch:")) { + const username = img.split(":")[1]; + return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`; + } - socket.addEventListener("message", (event) => { - const payload = JSON.parse(event.data); + return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; +} - if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { - updatePresence(payload.d); - updateElapsedAndProgress(); +async function populateReviews(userId) { + if (!reviewURL || !userId || isLoadingReviews || !hasMoreReviews) return; + const reviewSection = document.getElementById("reviews-section"); + const reviewList = reviewSection?.querySelector(".reviews-list"); + if (!reviewList) return; + + isLoadingReviews = true; + + try { + const url = `${reviewURL}/users/${userId}/reviews?flags=2&offset=${currentReviewOffset}`; + const res = await fetch(url); + const data = await res.json(); + + if (!data.success || !Array.isArray(data.reviews)) { + if (currentReviewOffset === 0) reviewSection.classList.add("hidden"); + isLoadingReviews = false; + return; } - }); + + const reviewsHTML = data.reviews + .map((review) => { + const sender = review.sender; + const username = sender.username; + const avatar = sender.profilePhoto; + let comment = review.comment; + + comment = comment.replace( + /<(a?):\w+:(\d+)>/g, + (_, animated, id) => + `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) { @@ -118,90 +303,362 @@ function buildActivityHTML(activity) { ? 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`; + let smallArt = null; + + if (activity.assets) { + art = resolveActivityImage( + activity.assets.large_image, + activity.application_id, + ); + smallArt = resolveActivityImage( + activity.assets.small_image, + activity.application_id, + ); } + const activityTypeMap = { + 0: "Playing", + 1: "Streaming", + 2: "Listening to", + 3: "Watching", + 4: "Custom Status", + 5: "Competing", + }; + + const activityType = activityTypeMap[activity.type] + ? `${activityTypeMap[activity.type]}${activity.type === 2 ? ` ${activity.name}` : ""}` + : "Playing"; + const activityTimestamp = - !total && start - ? ` -
    - - Since: ${new Date(start).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} - -
    ` + start && progress === null + ? `
    + 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 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('')} -
    ` - : ''; + 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 = `
    + + ${``} +
    `; return `
  • - ${art ? `Art` : ""} -
    -
    - ${activity.name} +
    +
    + ${activityType} ${activityTimestamp}
    - ${activity.details ? `
    ${activity.details}
    ` : ""} - ${activity.state ? `
    ${activity.state}
    ` : ""} - ${activityButtons} +
    + ${activityArt} +
    +
    +
    +
    + ${primaryLine} +
    + ${secondaryLine ? `
    ${secondaryLine}
    ` : ""} + ${tertiaryLine ? `
    ${tertiaryLine}
    ` : ""} +
    +
    + ${activityButtons} +
    +
    +
    +
    ${progressBar}
  • `; } -function updatePresence(data) { - const avatarWrapper = document.querySelector(".avatar-wrapper"); - const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); - const mobileIcon = avatarWrapper?.querySelector( - ".platform-icon.mobile-only", - ); +async function loadBadges(userId, options = {}) { + const { + services = [], + seperated = false, + cache = true, + targetId = "badges", + serviceOrder = [], + } = options; - const userInfo = document.querySelector(".user-info"); - const customStatus = userInfo?.querySelector(".custom-status"); + 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; + } + + const avatarWrapper = document.querySelector(".avatar-wrapper"); + const avatarImg = avatarWrapper?.querySelector(".avatar"); + const decorationImg = avatarWrapper?.querySelector(".decoration"); + const usernameEl = document.querySelector(".username"); + + 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 platform = { mobile: data.active_on_discord_mobile, @@ -209,42 +666,257 @@ function updatePresence(data) { desktop: data.active_on_discord_desktop, }; - if (statusIndicator) { - statusIndicator.className = `status-indicator ${data.discord_status}`; + let status = "offline"; + if (data.activities.some((activity) => activity.type === 1)) { + status = "streaming"; + } else { + status = data.discord_status; } - if (platform.mobile && !mobileIcon) { - avatarWrapper.innerHTML += ` - - - + 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 = ` + `; - } else if (!platform.mobile && mobileIcon) { - mobileIcon.remove(); - 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}`; } const custom = data.activities?.find((a) => a.type === 4); - 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}`; - } + 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; + }); - const filtered = data.activities?.filter((a) => a.type !== 4); const activityList = document.querySelector(".activities"); + const activitiesTitle = document.querySelector(".activity-block-header"); - if (activityList) { - activityList.innerHTML = ""; + if (activityList && activitiesTitle) { 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 new file mode 100644 index 0000000..1e51d6e --- /dev/null +++ b/public/js/rain.js @@ -0,0 +1,88 @@ +const rainContainer = document.createElement("div"); +rainContainer.style.position = "fixed"; +rainContainer.style.top = "0"; +rainContainer.style.left = "0"; +rainContainer.style.width = "100vw"; +rainContainer.style.height = "100vh"; +rainContainer.style.pointerEvents = "none"; +document.body.appendChild(rainContainer); + +const maxRaindrops = 100; +const raindrops = []; +const mouse = { x: -100, y: -100 }; + +document.addEventListener("mousemove", (e) => { + mouse.x = e.clientX; + mouse.y = e.clientY; +}); + +const getRaindropColor = () => { + const htmlTag = document.documentElement; + return htmlTag.getAttribute("data-theme") === "dark" + ? "rgba(173, 216, 230, 0.8)" + : "rgba(70, 130, 180, 0.8)"; +}; + +const createRaindrop = () => { + if (raindrops.length >= maxRaindrops) { + const oldest = raindrops.shift(); + rainContainer.removeChild(oldest); + } + + const raindrop = document.createElement("div"); + raindrop.classList.add("raindrop"); + raindrop.style.position = "absolute"; + const height = Math.random() * 10 + 10; + raindrop.style.width = "2px"; + raindrop.style.height = `${height}px`; + raindrop.style.background = getRaindropColor(); + raindrop.style.borderRadius = "1px"; + raindrop.style.opacity = Math.random() * 0.5 + 0.3; + + raindrop.x = Math.random() * window.innerWidth; + raindrop.y = -height; + raindrop.speed = Math.random() * 6 + 4; + raindrop.directionX = (Math.random() - 0.5) * 0.2; + raindrop.directionY = Math.random() * 0.5 + 0.8; + + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; + + raindrops.push(raindrop); + rainContainer.appendChild(raindrop); +}; + +setInterval(createRaindrop, 50); + +function updateRaindrops() { + raindrops.forEach((raindrop, index) => { + const height = Number.parseFloat(raindrop.style.height); + + raindrop.x += raindrop.directionX * raindrop.speed; + raindrop.y += raindrop.directionY * raindrop.speed; + + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; + + if (raindrop.y > window.innerHeight) { + rainContainer.removeChild(raindrop); + raindrops.splice(index, 1); + return; + } + + if ( + raindrop.x > window.innerWidth || + raindrop.y > window.innerHeight || + raindrop.x < 0 + ) { + raindrop.x = Math.random() * window.innerWidth; + raindrop.y = -height; + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; + } + }); + + requestAnimationFrame(updateRaindrops); +} + +updateRaindrops(); diff --git a/public/js/snow.js b/public/js/snow.js new file mode 100644 index 0000000..4d3e755 --- /dev/null +++ b/public/js/snow.js @@ -0,0 +1,95 @@ +const snowContainer = document.createElement("div"); +snowContainer.style.position = "fixed"; +snowContainer.style.top = "0"; +snowContainer.style.left = "0"; +snowContainer.style.width = "100vw"; +snowContainer.style.height = "100vh"; +snowContainer.style.pointerEvents = "none"; +document.body.appendChild(snowContainer); + +const maxSnowflakes = 60; +const snowflakes = []; +const mouse = { x: -100, y: -100 }; + +document.addEventListener("mousemove", (e) => { + mouse.x = e.clientX; + mouse.y = e.clientY; +}); + +const createSnowflake = () => { + if (snowflakes.length >= maxSnowflakes) { + const oldestSnowflake = snowflakes.shift(); + snowContainer.removeChild(oldestSnowflake); + } + + const snowflake = document.createElement("div"); + snowflake.classList.add("snowflake"); + snowflake.style.position = "absolute"; + const size = Math.random() * 3 + 2; + snowflake.style.width = `${size}px`; + snowflake.style.height = `${size}px`; + snowflake.style.background = "white"; + snowflake.style.borderRadius = "50%"; + snowflake.style.opacity = Math.random(); + + snowflake.x = Math.random() * window.innerWidth; + snowflake.y = -size; + snowflake.speed = Math.random() * 3 + 2; + snowflake.directionX = (Math.random() - 0.5) * 0.5; + snowflake.directionY = Math.random() * 0.5 + 0.5; + + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; + + snowflakes.push(snowflake); + snowContainer.appendChild(snowflake); +}; + +setInterval(createSnowflake, 80); + +function updateSnowflakes() { + snowflakes.forEach((snowflake, index) => { + const size = Number.parseFloat(snowflake.style.width); + const centerX = snowflake.x + size / 2; + const centerY = snowflake.y + size / 2; + + const dx = centerX - mouse.x; + const dy = centerY - mouse.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 30) { + snowflake.directionX += (dx / distance) * 0.02; + snowflake.directionY += (dy / distance) * 0.02; + } else { + snowflake.directionX += (Math.random() - 0.5) * 0.01; + snowflake.directionY += (Math.random() - 0.5) * 0.01; + } + + snowflake.x += snowflake.directionX * snowflake.speed; + snowflake.y += snowflake.directionY * snowflake.speed; + + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; + + if (snowflake.y > window.innerHeight) { + snowContainer.removeChild(snowflake); + snowflakes.splice(index, 1); + return; + } + + if ( + snowflake.x > window.innerWidth || + snowflake.y > window.innerHeight || + snowflake.x < 0 + ) { + snowflake.x = Math.random() * window.innerWidth; + snowflake.y = -size; + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; + } + }); + + requestAnimationFrame(updateSnowflakes); +} + +updateSnowflakes(); diff --git a/public/js/stars.js b/public/js/stars.js new file mode 100644 index 0000000..6fff4eb --- /dev/null +++ b/public/js/stars.js @@ -0,0 +1,63 @@ +const container = document.createElement("div"); +container.style.position = "fixed"; +container.style.top = "0"; +container.style.left = "0"; +container.style.width = "100vw"; +container.style.height = "100vh"; +container.style.pointerEvents = "none"; +container.style.overflow = "hidden"; +container.style.zIndex = "9999"; +document.body.appendChild(container); + +for (let i = 0; i < 60; i++) { + const star = document.createElement("div"); + star.className = "star"; + const size = Math.random() * 2 + 1; + star.style.width = `${size}px`; + star.style.height = `${size}px`; + star.style.opacity = Math.random(); + star.style.top = `${Math.random() * 100}vh`; + star.style.left = `${Math.random() * 100}vw`; + star.style.animationDuration = `${Math.random() * 3 + 2}s`; + container.appendChild(star); +} + +function createShootingStar() { + const star = document.createElement("div"); + star.className = "shooting-star"; + + star.x = Math.random() * window.innerWidth * 0.8; + star.y = Math.random() * window.innerHeight * 0.3; + const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; + const speed = 10; + const totalFrames = 60; + let frame = 0; + + const deg = angle * (180 / Math.PI); + star.style.left = `${star.x}px`; + star.style.top = `${star.y}px`; + star.style.transform = `rotate(${deg}deg)`; + + container.appendChild(star); + + function animate() { + star.x += Math.cos(angle) * speed; + star.y += Math.sin(angle) * speed; + star.style.left = `${star.x}px`; + star.style.top = `${star.y}px`; + star.style.opacity = `${1 - frame / totalFrames}`; + + frame++; + if (frame < totalFrames) { + requestAnimationFrame(animate); + } else if (star.parentNode === container) { + container.removeChild(star); + } + } + + animate(); +} + +setInterval(() => { + if (Math.random() < 0.3) createShootingStar(); +}, 1000); diff --git a/src/helpers/char.ts b/src/helpers/char.ts deleted file mode 100644 index 6ecab40..0000000 --- a/src/helpers/char.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function timestampToReadable(timestamp?: number): string { - const date: Date = - timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (isNaN(date.getTime())) return "Invalid Date"; - return date.toISOString().replace("T", " ").replace("Z", ""); -} diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts deleted file mode 100644 index 6b03dd0..0000000 --- a/src/helpers/ejs.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { renderFile } from "ejs"; -import { resolve } from "path"; - -export async function renderEjsTemplate( - viewName: string | string[], - data: EjsTemplateData, - headers?: Record, -): 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 deleted file mode 100644 index f226f91..0000000 --- a/src/helpers/lanyard.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 331be1d..0000000 --- a/src/helpers/logger.ts +++ /dev/null @@ -1,205 +0,0 @@ -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 6d2801d..71c6418 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,17 @@ -import { logger } from "@helpers/logger"; - import { serverHandler } from "@/server"; +import { echo } from "@atums/echo"; +import { verifyRequiredVariables } from "@config/environment"; async function main(): Promise { - try { - serverHandler.initialize(); - } catch (error) { - throw error; - } + verifyRequiredVariables(); + serverHandler.initialize(); } main().catch((error: Error) => { - logger.error(["Error initializing the server:", error]); + echo.error({ message: "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 25d8f30..06c32e7 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -1,6 +1,12 @@ -import { lanyardConfig } from "@config/environment"; -import { renderEjsTemplate } from "@helpers/ejs"; -import { getLanyardData, handleReadMe } from "@helpers/lanyard"; +import { resolve } from "node:path"; +import { + badgeApi, + lanyardConfig, + plausibleScript, + reviewDb, + timezoneAPIUrl, +} from "@config/environment"; +import { file } from "bun"; const routeDef: RouteDef = { method: "GET", @@ -10,44 +16,40 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { id } = request.params; - const data: LanyardResponse = await getLanyardData(id); + const instance = lanyardConfig.instance + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); - if (!data.success) { - return await renderEjsTemplate("error", { - message: data.error.message, - }); - } + const path = resolve("src", "views", "index.html"); + const bunFile = file(path); - let instance: string = lanyardConfig.instance; + 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 || ""); - if (instance.endsWith("/")) { - instance = instance.slice(0, -1); - } + if (reviewDb.enabled) { + head.setAttribute("data-review-db", reviewDb.url); + } - if (instance.startsWith("http://") || instance.startsWith("https://")) { - instance = instance.slice(instance.indexOf("://") + 3); - } + if (timezoneAPIUrl) { + head.setAttribute("data-timezone-api", timezoneAPIUrl); + } - const presence: LanyardData = data.data; - const readme: string | Promise | null = - await handleReadMe(presence); + if (plausibleScript) { + head.append(plausibleScript, { html: true }); + } + }, + }) + .transform(await bunFile.text()); - 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, + return new Response(html, { + headers: { + "Content-Type": "text/html", }, - 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 new file mode 100644 index 0000000..c91b7b4 --- /dev/null +++ b/src/routes/api/art[game].ts @@ -0,0 +1,93 @@ +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 deleted file mode 100644 index d8817ca..0000000 --- a/src/routes/api/colors.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 new file mode 100644 index 0000000..92c4f37 --- /dev/null +++ b/src/routes/api/css.ts @@ -0,0 +1,85 @@ +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 new file mode 100644 index 0000000..ca94922 --- /dev/null +++ b/src/routes/api/readme.ts @@ -0,0 +1,139 @@ +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 692fc59..e3b78cb 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,54 +1,13 @@ -import { lanyardConfig } from "@config/environment"; -import { renderEjsTemplate } from "@helpers/ejs"; -import { getLanyardData, handleReadMe } from "@helpers/lanyard"; +import { handler as idHandler, routeDef as idRouteDef } from "./[id]"; -const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "text/html", +export const routeDef = { + ...idRouteDef, }; -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 }; +export const handler = async ( + request: ExtendedRequest, + body: unknown, + server: BunServer, +) => { + return await idHandler(request); +}; diff --git a/src/server.ts b/src/server.ts index 13ae2ba..e851a58 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,29 +34,23 @@ 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 = [ + const accessUrls: string[] = [ `http://${server.hostname}:${server.port}`, `http://localhost:${server.port}`, `http://127.0.0.1:${server.port}`, ]; - logger.info(`Server running at ${accessUrls[0]}`, true); - logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); + echo.info(`Server running at ${accessUrls[0]}`); + echo.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`); this.logRoutes(); } private logRoutes(): void { - logger.info("Available routes:"); + echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -65,14 +59,19 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - logger.info(`Route: ${path}, File: ${filePath}`); + echo.info(`Route: ${path}, File: ${filePath}`); } } - private async serveStaticFile(pathname: string): Promise { - try { - let filePath: string; + private async serveStaticFile( + request: ExtendedRequest, + pathname: string, + ip: string, + ): Promise { + let filePath: string; + let response: Response; + try { if (pathname === "/favicon.ico") { filePath = resolve("public", "assets", "favicon.ico"); } else { @@ -83,23 +82,49 @@ 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"; - return new Response(fileContent, { + response = new Response(fileContent, { headers: { "Content-Type": contentType }, }); } else { - logger.warn(`File not found: ${filePath}`); - return new Response("Not Found", { status: 404 }); + echo.warn(`File not found: ${filePath}`); + response = new Response("Not Found", { status: 404 }); } } catch (error) { - logger.error([ - `Error serving static file: ${pathname}`, - error as Error, - ]); - return new Response("Internal Server Error", { status: 500 }); + echo.error({ + message: `Error serving static file: ${pathname}`, + error: error as Error, + }); + response = 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( @@ -109,22 +134,51 @@ 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(pathname); + return await this.serveStaticFile(extendedRequest, pathname, ip); } 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; @@ -151,9 +205,7 @@ 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) ) { @@ -178,9 +230,7 @@ class ServerHandler { if (Array.isArray(expectedContentType)) { matchesAccepts = expectedContentType.includes("*/*") || - expectedContentType.includes( - actualContentType || "", - ); + expectedContentType.includes(actualContentType || ""); } else { matchesAccepts = expectedContentType === "*/*" || @@ -219,10 +269,10 @@ class ServerHandler { } } } catch (error: unknown) { - logger.error([ - `Error handling route ${request.url}:`, - error as Error, - ]); + echo.error({ + message: `Error handling route ${request.url}`, + error: error, + }); response = Response.json( { @@ -244,31 +294,11 @@ class ServerHandler { ); } - const headers: Headers = response.headers; - let ip: string | null = server.requestIP(request)?.address || null; - - if (!ip) { - ip = - headers.get("CF-Connecting-IP") || - headers.get("X-Real-IP") || - headers.get("X-Forwarded-For") || - null; - } - - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - + this.logRequest(extendedRequest, response, ip); return response; } } + const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/src/views/error.ejs b/src/views/error.ejs deleted file mode 100644 index 1a683f1..0000000 --- a/src/views/error.ejs +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Error - - - - -
    -
    Something went wrong
    -
    - <%= message || "An unexpected error occurred." %> -
    -
    - - diff --git a/src/views/index.ejs b/src/views/index.ejs deleted file mode 100644 index 6790bb5..0000000 --- a/src/views/index.ejs +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - <%= 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 new file mode 100644 index 0000000..5f71625 --- /dev/null +++ b/src/views/index.html @@ -0,0 +1,73 @@ + + + + + + + + + + Discord Presence + + + + + + + +
    +
    +
    + +
    + + + + + +
    + +
    +
    +
    +
    + + + +
    + +
    +
    + + + +
    + +
      +
      + + +
      + + + + + + + \ No newline at end of file diff --git a/src/websocket.ts b/src/websocket.ts index ce87fe8..3b00134 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,31 +1,27 @@ -import { logger } from "@helpers/logger"; -import { type ServerWebSocket } from "bun"; +import { echo } from "@atums/echo"; +import type { ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { - logger.info(`WebSocket received: ${message}`); + echo.info(`WebSocket received: ${message}`); try { ws.send(`You said: ${message}`); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ message: "WebSocket send error", error: error }); } } public handleOpen(ws: ServerWebSocket): void { - logger.info("WebSocket connection opened."); + echo.info("WebSocket connection opened."); try { ws.send("Welcome to the WebSocket server!"); } catch (error) { - logger.error(["WebSocket send error", error as Error]); + echo.error({ message: "WebSocket send error", error: error }); } } - public handleClose( - ws: ServerWebSocket, - code: number, - reason: string, - ): void { - logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + public handleClose(ws: ServerWebSocket, code: number, reason: string): void { + echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } } diff --git a/tsconfig.json b/tsconfig.json index ac5f2c7..68a5a97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,28 +2,14 @@ "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", @@ -41,11 +27,7 @@ // 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 deleted file mode 100644 index 486a4a4..0000000 --- a/types/ejs.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface EjsTemplateData { - [key: string]: string | number | boolean | object | undefined | null; -} diff --git a/types/lanyard.d.ts b/types/lanyard.d.ts deleted file mode 100644 index 9c0e136..0000000 --- a/types/lanyard.d.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index ff6a601..0000000 --- a/types/logger.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 9d9d809..afb9b2a 100644 --- a/types/routes.d.ts +++ b/types/routes.d.ts @@ -3,6 +3,7 @@ type RouteDef = { accepts: string | null | string[]; returns: string; needsBody?: "multipart" | "json"; + log?: boolean; }; type RouteModule = {