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/.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 4a841f7..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 index 6596881..d93a942 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,28 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2025 [creations.works] +Copyright (c) 2025, creations.works -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +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 81bfda2..f4b9394 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,36 @@ # Discord Profile Page -A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS. +A cool little web app that shows your Discord profile, current activity, and more. Built with Bun. -## Prerequisite: Lanyard Backend +# Preview +https://creations.works -This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance for Discord presence data. +--- -Make sure Lanyard is running and accessible before using this profile page. +## 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 --- @@ -28,35 +52,66 @@ Copy the example environment file and update it: cp .env.example .env ``` -#### Required `.env` Variables +#### `.env` Variables -| Variable | Description | -|--------------------|--------------------------------------------------| -| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) | -| `PORT` | Port to run the server on (default: `8080`) | -| `LANYARD_USER_ID` | Your Discord user ID | -| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL | +| 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 Vars (per-user customization) +#### Optional Lanyard KV Variables (per-user customization) -These are expected to be defined in Lanyard's KV store: +These can be defined in Lanyard's KV store to customize the page: -| Variable | Description | -|-----------|-------------------------------------------------------------| -| `snow` | Enables snow background effect (`true`) | -| `rain` | Enables rain background effect (`true`) | -| `readme` | URL to a README file displayed on your profile | -| `colors` | Enables avatar-based color theme (uses `node-vibrant`) | +| 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 App +### 3. Start the Instance ```bash bun run start ``` -Then open `http://localhost:8080` in your browser. +--- + +## 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`. --- @@ -68,20 +123,23 @@ Then open `http://localhost:8080` in your browser. docker compose up -d --build ``` -Make sure your `.env` file is correctly configured before starting. +Make sure the `.env` file is configured correctly before starting the container. --- -## Tech Stack +## Routes -- Bun – Runtime -- EJS – Templating -- CSS – Styling -- node-vibrant – Avatar color extraction -- Biome.js – Linting and formatting +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 -[MIT](/LICENSE) +[BSD 3](LICENSE) diff --git a/biome.json b/biome.json index fa06b32..46ee8c9 100644 --- a/biome.json +++ b/biome.json @@ -26,7 +26,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "noUnusedImports": "error" + } } }, "javascript": { 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 239d2c3..2c89b09 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,11 +1,66 @@ -export const environment: Environment = { +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"), }; -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/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 4627118..5654ef1 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,13 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/bun": "^1.2.8", - "@types/ejs": "^3.1.5", "globals": "^16.0.0" }, "peerDependencies": { "typescript": "^5.8.3" }, "dependencies": { - "ejs": "^3.1.10", - "isomorphic-dompurify": "^2.23.0", - "marked": "^15.0.7", - "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 65dce6b..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 ac5f70f..8960831 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,3 +1,79 @@ +.raindrop { + position: absolute; + background-color: white; + border-radius: 50%; + pointer-events: none; + z-index: 1; +} + +.star, +.snowflake { + position: absolute; + background-color: white; + border-radius: 50%; + pointer-events: none; + z-index: 1; +} + +.star { + animation: twinkle ease-in-out infinite alternate; +} + +.shooting-star { + position: absolute; + background: linear-gradient(90deg, white, transparent); + width: 100px; + height: 2px; + opacity: 0.8; + border-radius: 2px; + transform-origin: left center; +} + +@keyframes twinkle { + from { + opacity: 0.3; + transform: scale(1); + } + to { + opacity: 1; + transform: scale(1.2); + } +} + +#loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 99999; + + transition: opacity 0.5s ease; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 5px solid var(--border-color); + border-top: 5px solid var(--progress-fill); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* actual styles below */ body { font-family: system-ui, sans-serif; background-color: var(--background); @@ -9,24 +85,37 @@ body { align-items: center; } -.snowflake { - position: absolute; - background-color: white; - border-radius: 50%; - pointer-events: none; - z-index: 1; +main { + width: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; } -.raindrop { - position: absolute; - background-color: white; - border-radius: 50%; - pointer-events: none; - z-index: 1; +.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; + display: none !important; } .activity-header.hidden { @@ -38,14 +127,17 @@ body { 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 { @@ -60,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; } @@ -104,11 +219,30 @@ body { .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 { @@ -116,6 +250,56 @@ 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; @@ -152,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 { @@ -160,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; } @@ -173,10 +377,12 @@ ul { padding: 0.75rem 1rem; border-radius: 10px; border: 1px solid var(--border-color); -} -.activity:hover { - background: var(--card-hover-bg); + transition: background-color 0.3s ease; + + &:hover { + background: var(--card-hover-bg); + } } .activity-wrapper { @@ -197,6 +403,10 @@ ul { height: 80px; } +.no-asset { + display: none !important; +} + .activity-image-small { width: 25px; height: 25px; @@ -204,6 +414,7 @@ ul { object-fit: cover; flex-shrink: 0; border-color: var(--card-bg); + background-color: var(--card-bg); border-width: 2px; border-style: solid; @@ -304,7 +515,7 @@ ul { text-transform: uppercase; font-weight: 600; color: var(--blockquote-color); - margin-bottom: 0.50rem; + margin-bottom: 0.5rem; display: block; } @@ -335,17 +546,17 @@ ul { text-decoration: none; transition: background-color 0.2s ease; display: inline-block; -} -.activity-button:hover { - background-color: var(--button-hover-bg); - text-decoration: none; -} + &:hover { + background-color: var(--button-hover-bg); + text-decoration: none; + } -.activity-button:disabled { - background-color: var(--button-disabled-bg); - cursor: not-allowed; - opacity: 0.8; + &:disabled { + background-color: var(--button-disabled-bg); + cursor: not-allowed; + opacity: 0.8; + } } @media (max-width: 600px) { @@ -362,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 { @@ -494,14 +715,16 @@ ul { /* readme :p */ .readme { - max-width: 700px; + max-width: fit-content; + min-width: 700px; + overflow: hidden; width: 100%; background: var(--readme-bg); padding: 1.5rem; border-radius: 8px; border: 1px solid var(--border-color); - margin-top: 2rem; + margin-top: 1rem; box-sizing: border-box; overflow: hidden; @@ -579,7 +802,8 @@ ul { @media (max-width: 600px) { .readme { - width: 100%; + max-width: 100%; + min-width: 100%; padding: 1rem; margin-top: 1rem; @@ -590,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 22e2dc6..68e05b6 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1,5 +1,23 @@ +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 hours = Math.floor(totalSecs / 3600); @@ -78,56 +96,14 @@ function updateElapsedAndProgress() { } } -updateElapsedAndProgress(); -setInterval(updateElapsedAndProgress, 1000); +function loadEffectScript(effect) { + const existing = document.querySelector(`script[data-effect="${effect}"]`); + if (existing) return; -const head = document.querySelector("head"); -const userId = head?.dataset.userId; -let instanceUri = head?.dataset.instanceUri; - -if (userId && instanceUri) { - if (!instanceUri.startsWith("http")) { - instanceUri = `https://${instanceUri}`; - } - - const wsUri = instanceUri - .replace(/^http:/, "ws:") - .replace(/^https:/, "wss:") - .replace(/\/$/, ""); - - const socket = new WebSocket(`${wsUri}/socket`); - - let heartbeatInterval = null; - - socket.addEventListener("open", () => {}); - - socket.addEventListener("message", (event) => { - const payload = JSON.parse(event.data); - - if (payload.op === 1 && payload.d?.heartbeat_interval) { - heartbeatInterval = setInterval(() => { - socket.send(JSON.stringify({ op: 3 })); - }, payload.d.heartbeat_interval); - - socket.send( - JSON.stringify({ - op: 2, - d: { - subscribe_to_id: userId, - }, - }), - ); - } - - if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { - updatePresence(payload.d); - requestAnimationFrame(() => updateElapsedAndProgress()); - } - }); - - socket.addEventListener("close", () => { - if (heartbeatInterval) clearInterval(heartbeatInterval); - }); + const script = document.createElement("script"); + script.src = `/public/js/${effect}.js`; + script.dataset.effect = effect; + document.head.appendChild(script); } function resolveActivityImage(img, applicationId) { @@ -146,9 +122,176 @@ function resolveActivityImage(img, applicationId) { return `https://i.scdn.co/image/${img.split(":")[1]}`; } + if (img.startsWith("twitch:")) { + const username = img.split(":")[1]; + return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`; + } + return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; } +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) { const start = activity.timestamps?.start; const end = activity.timestamps?.end; @@ -177,18 +320,15 @@ function buildActivityHTML(activity) { const activityTypeMap = { 0: "Playing", 1: "Streaming", - 2: "Listening", + 2: "Listening to", 3: "Watching", 4: "Custom Status", 5: "Competing", }; - const activityType = - activity.name === "Spotify" - ? "Listening to Spotify" - : activity.name === "TIDAL" - ? "Listening to TIDAL" - : activityTypeMap[activity.type] || "Playing"; + const activityType = activityTypeMap[activity.type] + ? `${activityTypeMap[activity.type]}${activity.type === 2 ? ` ${activity.name}` : ""}` + : "Playing"; const activityTimestamp = start && progress === null @@ -201,26 +341,36 @@ function buildActivityHTML(activity) { ` : ""; - const activityButtons = - activity.buttons && activity.buttons.length > 0 - ? `
    - ${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) - .join("")} -
    ` - : ""; + 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 @@ -239,12 +389,15 @@ function buildActivityHTML(activity) { const secondaryLine = isMusic ? activity.state : activity.details; const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state; - const activityArt = art - ? `
    - Art - ${smallArt ? `Small Art` : ""} -
    ` - : ""; + const activityArt = `
    + + ${``} +
    `; return `
  • @@ -276,13 +429,236 @@ function buildActivityHTML(activity) { `; } -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, @@ -297,36 +673,59 @@ function updatePresence(data) { status = data.discord_status; } - if (statusIndicator) { - statusIndicator.className = `status-indicator ${status}`; + for (const el of avatarWrapper.querySelectorAll(".platform-icon")) { + const platformType = ["mobile-only", "desktop-only", "web-only"].find( + (type) => el.classList.contains(type), + ); + + if (!platformType) continue; + + const active = + (platformType === "mobile-only" && platform.mobile) || + (platformType === "desktop-only" && platform.desktop) || + (platformType === "web-only" && platform.web); + + if (!active) { + el.remove(); + } else { + el.setAttribute("class", `platform-icon ${platformType} ${status}`); + } } - if (platform.mobile && !mobileIcon) { - avatarWrapper.innerHTML += ` - - - + 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 ? `${custom.state}` : ""} - `; - } + updateCustomStatus(custom); + + populateReadme(data); const filtered = data.activities ?.filter((a) => a.type !== 4) @@ -338,7 +737,7 @@ function updatePresence(data) { }); const activityList = document.querySelector(".activities"); - const activitiesTitle = document.querySelector(".activity-header"); + const activitiesTitle = document.querySelector(".activity-block-header"); if (activityList && activitiesTitle) { if (filtered?.length) { @@ -349,5 +748,175 @@ function updatePresence(data) { 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 index 27c0d8f..1e51d6e 100644 --- a/public/js/rain.js +++ b/public/js/rain.js @@ -25,24 +25,29 @@ const getRaindropColor = () => { const createRaindrop = () => { if (raindrops.length >= maxRaindrops) { - const oldestRaindrop = raindrops.shift(); - rainContainer.removeChild(oldestRaindrop); + 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 = `${Math.random() * 10 + 10}px`; + raindrop.style.height = `${height}px`; raindrop.style.background = getRaindropColor(); raindrop.style.borderRadius = "1px"; - raindrop.style.left = `${Math.random() * window.innerWidth}px`; - raindrop.style.top = `-${raindrop.style.height}`; raindrop.style.opacity = Math.random() * 0.5 + 0.3; + + raindrop.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); }; @@ -51,23 +56,29 @@ setInterval(createRaindrop, 50); function updateRaindrops() { raindrops.forEach((raindrop, index) => { - const rect = raindrop.getBoundingClientRect(); + const height = Number.parseFloat(raindrop.style.height); - raindrop.style.left = `${rect.left + raindrop.directionX * raindrop.speed}px`; - raindrop.style.top = `${rect.top + raindrop.directionY * raindrop.speed}px`; + raindrop.x += raindrop.directionX * raindrop.speed; + raindrop.y += raindrop.directionY * raindrop.speed; - if (rect.top + rect.height >= window.innerHeight) { + 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 ( - rect.left > window.innerWidth || - rect.top > window.innerHeight || - rect.left < 0 + raindrop.x > window.innerWidth || + raindrop.y > window.innerHeight || + raindrop.x < 0 ) { - raindrop.style.left = `${Math.random() * window.innerWidth}px`; - raindrop.style.top = `-${raindrop.style.height}`; + raindrop.x = Math.random() * window.innerWidth; + raindrop.y = -height; + raindrop.style.left = `${raindrop.x}px`; + raindrop.style.top = `${raindrop.y}px`; } }); diff --git a/public/js/snow.js b/public/js/snow.js index 05048a8..4d3e755 100644 --- a/public/js/snow.js +++ b/public/js/snow.js @@ -1,84 +1,95 @@ -document.addEventListener("DOMContentLoaded", () => { - const snowContainer = document.createElement("div"); - snowContainer.style.position = "fixed"; - snowContainer.style.top = "0"; - snowContainer.style.left = "0"; - snowContainer.style.width = "100vw"; - snowContainer.style.height = "100vh"; - snowContainer.style.pointerEvents = "none"; - document.body.appendChild(snowContainer); +const 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 }; +const maxSnowflakes = 60; +const snowflakes = []; +const mouse = { x: -100, y: -100 }; - document.addEventListener("mousemove", (e) => { - mouse.x = e.clientX; - mouse.y = e.clientY; - }); +document.addEventListener("mousemove", (e) => { + mouse.x = e.clientX; + mouse.y = e.clientY; +}); - const createSnowflake = () => { - if (snowflakes.length >= maxSnowflakes) { - const oldestSnowflake = snowflakes.shift(); - snowContainer.removeChild(oldestSnowflake); - } - - const snowflake = document.createElement("div"); - snowflake.classList.add("snowflake"); - snowflake.style.position = "absolute"; - snowflake.style.width = `${Math.random() * 3 + 2}px`; - snowflake.style.height = snowflake.style.width; - snowflake.style.background = "white"; - snowflake.style.borderRadius = "50%"; - snowflake.style.opacity = Math.random(); - snowflake.style.left = `${Math.random() * window.innerWidth}px`; - snowflake.style.top = `-${snowflake.style.height}`; - snowflake.speed = Math.random() * 3 + 2; - snowflake.directionX = (Math.random() - 0.5) * 0.5; - snowflake.directionY = Math.random() * 0.5 + 0.5; - - snowflakes.push(snowflake); - snowContainer.appendChild(snowflake); - }; - - setInterval(createSnowflake, 80); - - function updateSnowflakes() { - snowflakes.forEach((snowflake, index) => { - const rect = snowflake.getBoundingClientRect(); - - const dx = rect.left + rect.width / 2 - mouse.x; - const dy = rect.top + rect.height / 2 - mouse.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 30) { - snowflake.directionX += (dx / distance) * 0.02; - snowflake.directionY += (dy / distance) * 0.02; - } else { - snowflake.directionX += (Math.random() - 0.5) * 0.01; - snowflake.directionY += (Math.random() - 0.5) * 0.01; - } - - snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`; - snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`; - - if (rect.top + rect.height >= window.innerHeight) { - snowContainer.removeChild(snowflake); - snowflakes.splice(index, 1); - } - - if ( - rect.left > window.innerWidth || - rect.top > window.innerHeight || - rect.left < 0 - ) { - snowflake.style.left = `${Math.random() * window.innerWidth}px`; - snowflake.style.top = `-${snowflake.style.height}`; - } - }); - - requestAnimationFrame(updateSnowflakes); +const createSnowflake = () => { + if (snowflakes.length >= maxSnowflakes) { + const oldestSnowflake = snowflakes.shift(); + snowContainer.removeChild(oldestSnowflake); } - updateSnowflakes(); -}); + 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 17657b3..0000000 --- a/src/helpers/char.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function timestampToReadable(timestamp?: number): string { - const date: Date = - timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (Number.isNaN(date.getTime())) return "Invalid Date"; - return date.toISOString().replace("T", " ").replace("Z", ""); -} diff --git a/src/helpers/colors.ts b/src/helpers/colors.ts deleted file mode 100644 index 43a74e7..0000000 --- a/src/helpers/colors.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { fetch } from "bun"; -import { Vibrant } from "node-vibrant/node"; - -export async function getImageColors( - url: string, - hex?: boolean, -): Promise { - if (!url) return null; - - if (typeof url !== "string" || !url.startsWith("http")) return null; - - let res: Response; - try { - res = await fetch(url); - } catch { - return null; - } - - if (!res.ok) return null; - - const type: string | null = res.headers.get("content-type"); - if (!type?.startsWith("image/")) return null; - - const buffer: Buffer = Buffer.from(await res.arrayBuffer()); - const base64: string = buffer.toString("base64"); - const colors: Palette = await Vibrant.from(buffer).getPalette(); - - return { - img: `data:${type};base64,${base64}`, - colors: hex - ? { - Muted: rgbToHex(safeRgb(colors.Muted)), - LightVibrant: rgbToHex(safeRgb(colors.LightVibrant)), - Vibrant: rgbToHex(safeRgb(colors.Vibrant)), - LightMuted: rgbToHex(safeRgb(colors.LightMuted)), - DarkVibrant: rgbToHex(safeRgb(colors.DarkVibrant)), - DarkMuted: rgbToHex(safeRgb(colors.DarkMuted)), - } - : colors, - }; -} - -function safeRgb(swatch: Swatch | null | undefined): number[] { - return Array.isArray(swatch?.rgb) ? (swatch.rgb ?? [0, 0, 0]) : [0, 0, 0]; -} - -export function rgbToHex(rgb: number[]): string { - return `#${rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`; -} diff --git a/src/helpers/ejs.ts b/src/helpers/ejs.ts deleted file mode 100644 index 6544009..0000000 --- a/src/helpers/ejs.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { resolve } from "node:path"; -import { renderFile } from "ejs"; - -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 de78955..0000000 --- a/src/helpers/lanyard.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { lanyardConfig } from "@config/environment"; -import { fetch } from "bun"; -import DOMPurify from "isomorphic-dompurify"; -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 = 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; - - const html: string | null = await marked.parse(text); - const safe: string | null = DOMPurify.sanitize(html); - - return safe; - } catch { - return null; - } -} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts deleted file mode 100644 index 4cbb12b..0000000 --- a/src/helpers/logger.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Stats } from "node:fs"; -import { - type WriteStream, - createWriteStream, - existsSync, - mkdirSync, - statSync, -} from "node:fs"; -import { EOL } from "node:os"; -import { basename, join } from "node:path"; -import { environment } from "@config/environment"; -import { timestampToReadable } from "@helpers/char"; - -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 = 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 = ""; - - for (let i = 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 = 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 = 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 = 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 = 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 = 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 60606d4..71c6418 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +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 { + 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 16ea3c0..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,53 +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()); - let status: string; - if (presence.activities.some((activity) => activity.type === 1)) { - status = "streaming"; - } else { - status = presence.discord_status; - } - - const ejsTemplateData: EjsTemplateData = { - title: presence.discord_user.global_name || presence.discord_user.username, - username: - presence.discord_user.global_name || presence.discord_user.username, - status: 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, - allowSnow: presence.kv.snow || false, - allowRain: presence.kv.rain || false, - }; - - 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 ef97012..0000000 --- a/src/routes/api/colors.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getImageColors } from "@helpers/colors"; - -const routeDef: RouteDef = { - method: "GET", - accepts: "*/*", - returns: "application/json", -}; - -async function handler(request: ExtendedRequest): Promise { - const { url } = request.query; - - const result: ImageColorResult | null = await getImageColors(url, true); - await getImageColors(url); - - if (!result) { - return new Response("Invalid URL", { - status: 400, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - "Access-Control-Allow-Origin": "*", - }, - }); - } - - const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(result)); - - 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 b0d6a61..e3b78cb 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,71 +1,13 @@ -import { getImageColors } from "@/helpers/colors"; -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); - - let status: string; - if (presence.activities.some((activity) => activity.type === 1)) { - status = "streaming"; - } else { - status = presence.discord_status; - } - - let colors: ImageColorResult | null = null; - if (presence.kv.colors === "true") { - const avatar: string = presence.discord_user.avatar - ? `https://cdn.discordapp.com/avatars/${presence.discord_user.id}/${presence.discord_user.avatar}` - : `https://cdn.discordapp.com/embed/avatars/${presence.discord_user.discriminator || 1 % 5}`; - colors = await getImageColors(avatar, true); - } - - const ejsTemplateData: EjsTemplateData = { - title: presence.discord_user.global_name || presence.discord_user.username, - username: - presence.discord_user.global_name || presence.discord_user.username, - status: 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, - allowSnow: presence.kv.snow === "true", - allowRain: presence.kv.rain === "true", - colors: colors?.colors ?? {}, - }; - - 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 fc8d4b5..e851a58 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; +import { echo } from "@atums/echo"; import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; import { type BunFile, FileSystemRouter, @@ -43,14 +43,14 @@ class ServerHandler { `http://127.0.0.1:${server.port}`, ]; - logger.info(`Server running at ${accessUrls[0]}`); - 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, @@ -59,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 { @@ -79,16 +84,47 @@ class ServerHandler { const fileContent: ArrayBuffer = await file.arrayBuffer(); const contentType: string = file.type || "application/octet-stream"; - return new Response(fileContent, { + response = new Response(fileContent, { headers: { "Content-Type": contentType }, }); + } else { + echo.warn(`File not found: ${filePath}`); + response = new Response("Not Found", { status: 404 }); } - logger.warn(`File not found: ${filePath}`); - return new Response("Not Found", { status: 404 }); } catch (error) { - 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( @@ -98,14 +134,44 @@ 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; @@ -203,7 +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( { @@ -225,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 0db4f4b..0000000 --- a/src/views/index.ejs +++ /dev/null @@ -1,214 +0,0 @@ - - - - - - - - - - - - <%= title %> - - - - - <% if (allowSnow) { %> - - <% } %> - <% if(allowRain) { %> - - <% } %> - - - - -<%- include('partial/style.ejs') %> - - -
    -
    -
    - Avatar - <% if (user.avatar_decoration_data) { %> - Decoration - <% } %> - <% if (platform.mobile) { %> - - - - <% } else { %> -
    - <% } %> -
    - -
    -
    - - <% - let filtered = activities - .filter(a => a.type !== 4) - .sort((a, b) => { - const priority = { 2: 0, 1: 1, 3: 2 }; - const aPriority = priority[a.type] ?? 99; - const bPriority = priority[b.type] ?? 99; - return aPriority - bPriority; - }); - %> - -

    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; - - let art = null; - let smallArt = null; - - function resolveActivityImage(img, applicationId) { - if (!img) return null; - if (img.startsWith("mp:external/")) { - return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; - } - if (img.includes("/https/")) { - const clean = img.split("/https/")[1]; - return clean ? `https://${clean}` : null; - } - if (img.startsWith("spotify:")) { - return `https://i.scdn.co/image/${img.split(":")[1]}`; - } - return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; - } - - if (activity.assets) { - art = resolveActivityImage(activity.assets.large_image, activity.application_id); - smallArt = resolveActivityImage(activity.assets.small_image, activity.application_id); - } - - const activityTypeMap = { - 0: "Playing", - 1: "Streaming", - 2: "Listening", - 3: "Watching", - 4: "Custom Status", - 5: "Competing", - }; - - const activityType = activity.name === "Spotify" - ? "Listening to Spotify" - : activity.name === "TIDAL" - ? "Listening to TIDAL" - : activityTypeMap[activity.type] || "Playing"; - %> -
    • -
      -
      - <%= activityType %> - <% if (start && progress === null) { %> -
      - <% const started = new Date(start); %> - - Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> - -
      - <% } %> -
      -
      - <% if (art) { %> -
      - Art> - <% if (smallArt) { %> - Small Art> - <% } %> -
      - <% } %> -
      -
      - <% - 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; - %> -
      -
      - <%= primaryLine %> -
      - <% if (secondaryLine) { %> -
      <%= secondaryLine %>
      - <% } %> - <% if (tertiaryLine) { %> -
      <%= tertiaryLine %>
      - <% } %> -
      -
      - <% 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 %> - <% } %> - <% }) %> -
      - <% } %> -
      -
      -
      -
      - <% if (progress !== null) { %> -
      -
      >
      -
      - <% if (start && end) { %> -
      - - <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %> -
      - <% } %> - <% } %> -
      -
    • - <% }); %> -
    - - <% if (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/views/partial/style.ejs b/src/views/partial/style.ejs deleted file mode 100644 index cccf3a2..0000000 --- a/src/views/partial/style.ejs +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/src/websocket.ts b/src/websocket.ts index 99686e8..3b00134 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,27 +1,27 @@ -import { logger } from "@helpers/logger"; +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}`); + echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } } 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 6af6a4b..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 = { @@ -13,10 +14,3 @@ type RouteModule = { ) => Promise | Response; routeDef: RouteDef; }; - -type Palette = Awaited>; -type Swatch = Awaited>; -type ImageColorResult = { - img: string; - colors: Palette | Record; -};