From c79ee2b203aa5b9a3e91fddde31da8273033a276 Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 6 Apr 2025 20:59:38 -0400 Subject: [PATCH 01/85] fix xss issue aka: https://git.creations.works/creations/profilePage/issues/3, update depends change how activities display, remove readme title, --- package.json | 9 +-- public/css/index.css | 97 ++++++++++++++++++------- public/js/index.js | 157 +++++++++++++++++++++++++++-------------- src/helpers/lanyard.ts | 6 +- src/server.ts | 10 +-- src/views/index.ejs | 118 +++++++++++++++++++++---------- 6 files changed, 270 insertions(+), 127 deletions(-) diff --git a/package.json b/package.json index 7d18b41..8d6e543 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unicorn": "^56.0.1", + "eslint-plugin-unicorn": "^58.0.0", "eslint-plugin-unused-imports": "^4.1.4", - "globals": "^15.15.0", + "globals": "^16.0.0", "prettier": "^3.5.3" }, "peerDependencies": { @@ -29,7 +29,8 @@ }, "dependencies": { "ejs": "^3.1.10", - "node-vibrant": "^4.0.3", - "marked": "^15.0.7" + "isomorphic-dompurify": "^2.23.0", + "marked": "^15.0.7", + "node-vibrant": "^4.0.3" } } diff --git a/public/css/index.css b/public/css/index.css index da470b9..e4acc03 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -142,18 +142,28 @@ ul { display: flex; flex-direction: row; gap: 1rem; - background: #1a1a1d; - padding: 1rem; - border-radius: 6px; - box-shadow: 0 0 0 1px #2e2e30; - transition: background 0.2s ease; - align-items: flex-start; + background-color: #1e1f22; + padding: 0.75rem 1rem; + border-radius: 10px; + box-shadow: 0 1px 0 0 #2e2e30; } .activity:hover { background: #2a2a2d; } +.activity-wrapper { + display: flex; + flex-direction: column; + width: 100%; +} + +.activity-wrapper-inner { + display: flex; + flex-direction: row; + gap: 1rem; +} + .activity-art { width: 80px; height: 80px; @@ -165,7 +175,15 @@ ul { .activity-content { display: flex; flex-direction: column; + justify-content: space-between; flex: 1; + gap: 0.5rem; + position: relative; +} + +.activity-top { + display: flex; + flex-direction: column; gap: 0.25rem; } @@ -175,36 +193,49 @@ ul { align-items: flex-start; } +.activity-bottom { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + .activity-name { - font-weight: bold; - font-size: 1.1rem; - color: #ffffff; + font-weight: 600; + font-size: 1rem; + color: #fff; } .activity-detail { - font-size: 0.95rem; - color: #ccc; + font-size: 0.875rem; + color: #b5bac1; } .activity-timestamp { - font-size: 0.8rem; - color: #777; + font-size: 0.75rem; + color: #b5bac1; text-align: right; + margin-left: auto; + white-space: nowrap; } .progress-bar { - height: 6px; - background-color: #333; - border-radius: 3px; - overflow: hidden; - width: 100%; + height: 4px; + background-color: #2e2e30; + border-radius: 2px; margin-top: 0.5rem; + overflow: hidden; } .progress-fill { + background-color: #5865f2; + transition: width 0.4s ease; height: 100%; - background-color: #00b0f4; - transition: width 0.5s ease; +} + +.progress-bar, +.progress-time-labels { + width: 100%; + margin-top: 0.5rem; } .progress-time-labels { @@ -215,6 +246,21 @@ ul { margin-top: 0.25rem; } +.activity-type-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.activity-type-label { + font-size: 0.75rem; + text-transform: uppercase; + font-weight: 600; + color: #aaa; + margin-bottom: 0.50rem; + display: block; +} + .activity-header.no-timestamp { justify-content: flex-start; } @@ -226,9 +272,9 @@ ul { .activity-buttons { display: flex; - flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; + justify-content: flex-end; } .activity-button { @@ -249,10 +295,9 @@ ul { text-decoration: none; } -.activity-button.disabled { - background-color: #4e5058; - cursor: default; - pointer-events: none; +.activity-button:disabled { + background-color: #2d2e31; + cursor: not-allowed; opacity: 0.8; } @@ -392,6 +437,8 @@ ul { border-radius: 8px; box-shadow: 0 0 0 1px #2e2e30; + margin-top: 2rem; + box-sizing: border-box; overflow: hidden; overflow-y: auto; diff --git a/public/js/index.js b/public/js/index.js index 6feec74..8b40b41 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -4,9 +4,20 @@ const activityProgressMap = new Map(); function formatTime(ms) { const totalSecs = Math.floor(ms / 1000); - const mins = Math.floor(totalSecs / 60); + const hours = Math.floor(totalSecs / 3600); + const mins = Math.floor((totalSecs % 3600) / 60); const secs = totalSecs % 60; - return `${mins}:${secs.toString().padStart(2, "0")}`; + + return `${String(hours).padStart(1, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; +} + +function formatVerbose(ms) { + const totalSecs = Math.floor(ms / 1000); + const hours = Math.floor(totalSecs / 3600); + const mins = Math.floor((totalSecs % 3600) / 60); + const secs = totalSecs % 60; + + return `${hours}h ${mins}m ${secs}s`; } function updateElapsedAndProgress() { @@ -17,17 +28,14 @@ function updateElapsedAndProgress() { if (!start) return; const elapsed = now - start; - const mins = Math.floor(elapsed / 60000); - const secs = Math.floor((elapsed % 60000) / 1000); const display = el.querySelector(".elapsed"); - if (display) - display.textContent = `(${mins}m ${secs.toString().padStart(2, "0")}s ago)`; + if (display) display.textContent = `(${formatVerbose(elapsed)} ago)`; }); document.querySelectorAll(".progress-bar").forEach((bar) => { const start = Number(bar.dataset.start); const end = Number(bar.dataset.end); - if (!start || !end || end <= start) return; + if (!start || !end || end <= start || now > end) return; const duration = end - start; const elapsed = now - start; @@ -43,7 +51,7 @@ function updateElapsedAndProgress() { document.querySelectorAll(".progress-time-labels").forEach((label) => { const start = Number(label.dataset.start); const end = Number(label.dataset.end); - if (!start || !end || end <= start) return; + if (!start || !end || end <= start || now > end) return; const current = Math.max(0, now - start); const total = end - start; @@ -102,7 +110,7 @@ if (userId && instanceUri) { if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { updatePresence(payload.d); - updateElapsedAndProgress(); + requestAnimationFrame(() => updateElapsedAndProgress()); } }); } @@ -129,64 +137,98 @@ function buildActivityHTML(activity) { art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; } + const activityTypeMap = { + 0: "Playing", + 1: "Streaming", + 2: "Listening", + 3: "Watching", + 4: "Custom Status", + 5: "Competing", + }; + + const activityType = + activity.name === "Spotify" + ? "Listening to Spotify" + : activity.name === "TIDAL" + ? "Listening to TIDAL" + : activityTypeMap[activity.type] || "Playing"; + const activityTimestamp = - !total && start - ? ` -
- - Since: ${new Date(start).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - })} - -
` + start && progress === null + ? `
+ Since: ${new Date(start).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} +
` + : ""; + + const 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 progressBar = progress !== null - ? ` -
+ ? `
${formatTime(elapsed)} ${formatTime(total)} -
- ` +
` : ""; - const activityButtons = activity.buttons && activity.buttons.length > 0 - ? `
- ${activity.buttons.map((button, index) => { - const buttonLabel = typeof button === 'string' ? button : button.label; - let buttonUrl = null; - if (typeof button === 'object' && button.url) { - buttonUrl = button.url; - } - else if (index === 0 && activity.url) { - buttonUrl = activity.url; - } - if (buttonUrl) { - return `${buttonLabel}`; - } else { - return `${buttonLabel}`; - } - }).join('')} -
` - : ''; + const isMusic = activity.type === 2 || activity.type === 3; + + const primaryLine = isMusic ? activity.details : activity.name; + const secondaryLine = isMusic ? activity.state : activity.details; + const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state; return `
  • - ${art ? `Art` : ""} -
    -
    - ${activity.name} +
    +
    + ${activityType} ${activityTimestamp}
    - ${activity.details ? `
    ${activity.details}
    ` : ""} - ${activity.state ? `
    ${activity.state}
    ` : ""} - ${activityButtons} +
    + ${art ? `Art` : ""} +
    +
    +
    +
    + ${primaryLine} +
    + ${secondaryLine ? `
    ${secondaryLine}
    ` : ""} + ${tertiaryLine ? `
    ${tertiaryLine}
    ` : ""} +
    +
    + ${activityButtons} +
    +
    +
    +
    ${progressBar}
  • @@ -234,10 +276,21 @@ function updatePresence(data) { } else if (emoji?.name) { emojiHTML = `${emoji.name} `; } - customStatus.innerHTML = `${emojiHTML}${custom.state}`; + customStatus.innerHTML = ` + ${emojiHTML} + ${custom.state ? `${custom.state}` : ""} + `; } - const filtered = data.activities?.filter((a) => a.type !== 4); + const filtered = data.activities + ?.filter((a) => a.type !== 4) + ?.sort((a, b) => { + const priority = { 2: 0, 1: 1, 3: 2 }; // Listening, Streaming, Watching ? should i keep this + const aPriority = priority[a.type] ?? 99; + const bPriority = priority[b.type] ?? 99; + return aPriority - bPriority; + }); + const activityList = document.querySelector(".activities"); if (activityList) { diff --git a/src/helpers/lanyard.ts b/src/helpers/lanyard.ts index f226f91..258b980 100644 --- a/src/helpers/lanyard.ts +++ b/src/helpers/lanyard.ts @@ -1,5 +1,6 @@ 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 { @@ -85,7 +86,10 @@ export async function handleReadMe(data: LanyardData): Promise { const text: string = await res.text(); if (!text || text.length < 10) return null; - return marked.parse(text); + const html: string | null = await marked.parse(text); + const safe: string | null = DOMPurify.sanitize(html); + + return safe; } catch { return null; } diff --git a/src/server.ts b/src/server.ts index 13ae2ba..2298034 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,22 +34,16 @@ class ServerHandler { open: webSocketHandler.handleOpen.bind(webSocketHandler), message: webSocketHandler.handleMessage.bind(webSocketHandler), close: webSocketHandler.handleClose.bind(webSocketHandler), - error(error) { - logger.error(`Server error: ${error.message}`); - return new Response(`Server Error: ${error.message}`, { - status: 500, - }); - }, }, }); - const accessUrls = [ + const accessUrls: string[] = [ `http://${server.hostname}:${server.port}`, `http://localhost:${server.port}`, `http://127.0.0.1:${server.port}`, ]; - logger.info(`Server running at ${accessUrls[0]}`, true); + logger.info(`Server running at ${accessUrls[0]}`); logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); this.logRoutes(); diff --git a/src/views/index.ejs b/src/views/index.ejs index 6790bb5..9b8dc31 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -46,14 +46,25 @@ <% } else if (emoji?.name) { %> <%= emoji.name %> <% } %> - <%= activities[0].state %> + <% if (activities[0].state) { %> + <%= activities[0].state %> + <% } %>

    <% } %> - <% const filtered = activities.filter(a => a.type !== 4); %> + <% + let filtered = activities + .filter(a => a.type !== 4) + .sort((a, b) => { + const priority = { 2: 0, 1: 1, 3: 2 }; // Listening, Streaming, Watching ? should i keep this + const aPriority = priority[a.type] ?? 99; + const bPriority = priority[b.type] ?? 99; + return aPriority - bPriority; + }); + %> <% if (filtered.length > 0) { %>

    Activities

      @@ -75,16 +86,28 @@ } else if (img) { art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; } + + 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"; %>
    • - <% if (art) { %> - Art - <% } %> - -
      -
      - <%= activity.name %> - +
      +
      + + <%= activityType %> + <% if (start && progress === null) { %>
      <% const started = new Date(start); %> @@ -95,33 +118,54 @@ <% } %>
      - <% if (activity.details) { %> -
      <%= activity.details %>
      - <% } %> - <% if (activity.state) { %> -
      <%= activity.state %>
      - <% } %> +
      + <% if (art) { %> + Art + <% } %> - <% if (activity.buttons && activity.buttons.length > 0) { %> -
      - <% activity.buttons.forEach((button, index) => { - const buttonLabel = typeof button === 'string' ? button : button.label; - let buttonUrl = null; - if (typeof button === 'object' && button.url) { - buttonUrl = button.url; - } - else if (index === 0 && activity.url) { - buttonUrl = activity.url; - } - %> - <% if (buttonUrl) { %> - <%= buttonLabel %> - <% } else { %> - <%= buttonLabel %> - <% } %> - <% }); %> +
      +
      + <% + 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) { %>
      @@ -130,7 +174,7 @@ <% if (start && end) { %>
      - --:-- + <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %>
      <% } %> @@ -140,8 +184,8 @@ <% }) %>
    <% } %> + <% if (readme) { %> -

    Readme

    <%- readme %>
    From 7d78a74a259f6fa335a6892eca54d22df490d7eb Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 6 Apr 2025 21:41:53 -0400 Subject: [PATCH 02/85] move to discord proxy for images, add lanyard hb, https://git.creations.works/creations/profilePage/pulls/6 --- public/js/index.js | 54 +++++++++++++++++++++++++++++++-------------- src/views/index.ejs | 6 ++++- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index 8b40b41..35dbe32 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -35,10 +35,10 @@ function updateElapsedAndProgress() { document.querySelectorAll(".progress-bar").forEach((bar) => { const start = Number(bar.dataset.start); const end = Number(bar.dataset.end); - if (!start || !end || end <= start || now > end) return; + if (!start || !end || end <= start) return; const duration = end - start; - const elapsed = now - start; + const elapsed = Math.min(now - start, duration); const progress = Math.min( 100, Math.max(0, Math.floor((elapsed / duration) * 100)), @@ -51,9 +51,10 @@ function updateElapsedAndProgress() { document.querySelectorAll(".progress-time-labels").forEach((label) => { const start = Number(label.dataset.start); const end = Number(label.dataset.end); - if (!start || !end || end <= start || now > end) return; + if (!start || !end || end <= start) return; - const current = Math.max(0, now - start); + const isPaused = now > end; + const current = isPaused ? end - start : Math.max(0, now - start); const total = end - start; const currentEl = label.querySelector(".progress-current"); @@ -62,7 +63,7 @@ function updateElapsedAndProgress() { const id = `${start}-${end}`; const last = activityProgressMap.get(id); - if (last !== undefined && last === current) { + if (isPaused || (last !== undefined && last === current)) { label.classList.add("paused"); } else { label.classList.remove("paused"); @@ -70,7 +71,11 @@ function updateElapsedAndProgress() { activityProgressMap.set(id, current); - if (currentEl) currentEl.textContent = formatTime(current); + if (currentEl) { + currentEl.textContent = isPaused + ? `Paused at ${formatTime(current)}` + : formatTime(current); + } if (totalEl) totalEl.textContent = formatTime(total); }); } @@ -94,25 +99,37 @@ if (userId && instanceUri) { const socket = new WebSocket(`${wsUri}/socket`); - socket.addEventListener("open", () => { - socket.send( - JSON.stringify({ - op: 2, - d: { - subscribe_to_id: userId, - }, - }), - ); - }); + 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); + }); } function buildActivityHTML(activity) { @@ -128,7 +145,10 @@ function buildActivityHTML(activity) { const img = activity.assets?.large_image; let art = null; - if (img?.includes("https")) { + + if (img?.startsWith("mp:external/")) { + art = `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; + } else if (img?.includes("/https/")) { const clean = img.split("/https/")[1]; if (clean) art = `https://${clean}`; } else if (img?.startsWith("spotify:")) { diff --git a/src/views/index.ejs b/src/views/index.ejs index 9b8dc31..4b1e1bb 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -78,7 +78,10 @@ const img = activity.assets?.large_image; let art = null; - if (img?.includes("https")) { + + if (img?.startsWith("mp:external/")) { + art = `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; + } else if (img?.includes("/https/")) { const clean = img.split("/https/")[1]; if (clean) art = `https://${clean}`; } else if (img?.startsWith("spotify:")) { @@ -87,6 +90,7 @@ art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; } + const activityTypeMap = { 0: "Playing", 1: "Streaming", From d91e832eab66af107e63da9396ca846fc239ea55 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 7 Apr 2025 04:36:07 -0400 Subject: [PATCH 03/85] move to biomejs --- .vscode/settings.json | 2 +- biome.json | 35 +++++++++++ config/environment.ts | 5 +- eslint.config.js | 132 --------------------------------------- package.json | 17 ++--- public/css/error.css | 2 +- public/css/index.css | 3 +- public/js/index.js | 31 ++++----- src/helpers/char.ts | 4 +- src/helpers/ejs.ts | 2 +- src/helpers/lanyard.ts | 2 +- src/helpers/logger.ts | 30 ++++----- src/index.ts | 6 +- src/routes/[id].ts | 3 +- src/routes/api/colors.ts | 5 +- src/routes/index.ts | 6 +- src/server.ts | 31 +++------ src/websocket.ts | 8 +-- tsconfig.json | 34 +++------- 19 files changed, 99 insertions(+), 259 deletions(-) create mode 100644 biome.json delete mode 100644 eslint.config.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 25117cc..4a841f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "github-enterprise.uri": "https://git.creations.works" + "github-enterprise.uri": "https://git.creations.works" } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3c4d4f1 --- /dev/null +++ b/biome.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "indentStyle": "tab", + "lineEnding": "lf", + "jsxQuoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/config/environment.ts b/config/environment.ts index c584b45..239d2c3 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,9 +1,8 @@ export const environment: Environment = { - port: parseInt(process.env.PORT || "8080", 10), + port: Number.parseInt(process.env.PORT || "8080", 10), host: process.env.HOST || "0.0.0.0", development: - process.env.NODE_ENV === "development" || - process.argv.includes("--dev"), + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), }; export const lanyardConfig: LanyardConfig = { diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index d43df76..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,132 +0,0 @@ -import pluginJs from "@eslint/js"; -import tseslintPlugin from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import prettier from "eslint-plugin-prettier"; -import promisePlugin from "eslint-plugin-promise"; -import simpleImportSort from "eslint-plugin-simple-import-sort"; -import unicorn from "eslint-plugin-unicorn"; -import unusedImports from "eslint-plugin-unused-imports"; -import globals from "globals"; - -/** @type {import('eslint').Linter.FlatConfig[]} */ -export default [ - { - files: ["**/*.{js,mjs,cjs}"], - languageOptions: { - globals: globals.node, - }, - ...pluginJs.configs.recommended, - plugins: { - "simple-import-sort": simpleImportSort, - "unused-imports": unusedImports, - promise: promisePlugin, - prettier: prettier, - unicorn: unicorn, - }, - rules: { - "eol-last": ["error", "always"], - "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }], - "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", - { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - }, - ], - "promise/always-return": "error", - "promise/no-return-wrap": "error", - "promise/param-names": "error", - "promise/catch-or-return": "error", - "promise/no-nesting": "warn", - "promise/no-promise-in-callback": "warn", - "promise/no-callback-in-promise": "warn", - "prettier/prettier": [ - "error", - { - useTabs: true, - tabWidth: 4, - }, - ], - indent: ["error", "tab", { SwitchCase: 1 }], - "unicorn/filename-case": [ - "error", - { - case: "camelCase", - }, - ], - }, - }, - { - files: ["**/*.{ts,tsx}"], - languageOptions: { - parser: tsParser, - globals: globals.node, - }, - plugins: { - "@typescript-eslint": tseslintPlugin, - "simple-import-sort": simpleImportSort, - "unused-imports": unusedImports, - promise: promisePlugin, - prettier: prettier, - unicorn: unicorn, - }, - rules: { - ...tseslintPlugin.configs.recommended.rules, - quotes: ["error", "double"], - "eol-last": ["error", "always"], - "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }], - "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", - { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - }, - ], - "promise/always-return": "error", - "promise/no-return-wrap": "error", - "promise/param-names": "error", - "promise/catch-or-return": "error", - "promise/no-nesting": "warn", - "promise/no-promise-in-callback": "warn", - "promise/no-callback-in-promise": "warn", - "prettier/prettier": [ - "error", - { - useTabs: true, - tabWidth: 4, - }, - ], - indent: ["error", "tab", { SwitchCase: 1 }], - "unicorn/filename-case": [ - "error", - { - case: "camelCase", - }, - ], - "@typescript-eslint/explicit-function-return-type": ["error"], - "@typescript-eslint/explicit-module-boundary-types": ["error"], - "@typescript-eslint/typedef": [ - "error", - { - arrowParameter: true, - variableDeclaration: true, - propertyDeclaration: true, - memberVariableDeclaration: true, - parameter: true, - }, - ], - }, - }, -]; diff --git a/package.json b/package.json index 8d6e543..793fe71 100644 --- a/package.json +++ b/package.json @@ -5,24 +5,15 @@ "scripts": { "start": "bun run src/index.ts", "dev": "bun run --hot src/index.ts --dev", - "lint": "eslint", - "lint:fix": "bun lint --fix", + "lint": "bunx biome check", + "lint:fix": "bunx biome check --fix", "cleanup": "rm -rf logs node_modules bun.lockdb" }, "devDependencies": { - "@eslint/js": "^9.24.0", + "@biomejs/biome": "^1.9.4", "@types/bun": "^1.2.8", "@types/ejs": "^3.1.5", - "@typescript-eslint/eslint-plugin": "^8.29.0", - "@typescript-eslint/parser": "^8.29.0", - "eslint": "^9.24.0", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-unicorn": "^58.0.0", - "eslint-plugin-unused-imports": "^4.1.4", - "globals": "^16.0.0", - "prettier": "^3.5.3" + "globals": "^16.0.0" }, "peerDependencies": { "typescript": "^5.8.3" diff --git a/public/css/error.css b/public/css/error.css index a8e591d..65dce6b 100644 --- a/public/css/error.css +++ b/public/css/error.css @@ -12,7 +12,7 @@ body { padding: 2rem; background: #1a1a1d; border-radius: 12px; - box-shadow: 0 0 20px rgba(0,0,0,0.3); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); } .error-title { font-size: 2rem; diff --git a/public/css/index.css b/public/css/index.css index e4acc03..59e3a37 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -107,7 +107,6 @@ h1 { flex-wrap: wrap; } - .custom-status .custom-emoji { width: 20px; height: 20px; @@ -379,7 +378,7 @@ ul { align-items: center; text-align: center; padding: 1rem; - border-radius:0; + border-radius: 0; } .activity-art { diff --git a/public/js/index.js b/public/js/index.js index 35dbe32..913773e 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1,5 +1,3 @@ -/* eslint-disable indent */ - const activityProgressMap = new Map(); function formatTime(ms) { @@ -23,19 +21,19 @@ function formatVerbose(ms) { function updateElapsedAndProgress() { const now = Date.now(); - document.querySelectorAll(".activity-timestamp").forEach((el) => { + for (const el of document.querySelectorAll(".activity-timestamp")) { const start = Number(el.dataset.start); - if (!start) return; + if (!start) continue; const elapsed = now - start; const display = el.querySelector(".elapsed"); if (display) display.textContent = `(${formatVerbose(elapsed)} ago)`; - }); + } - document.querySelectorAll(".progress-bar").forEach((bar) => { + for (const bar of document.querySelectorAll(".progress-bar")) { const start = Number(bar.dataset.start); const end = Number(bar.dataset.end); - if (!start || !end || end <= start) return; + if (!start || !end || end <= start) continue; const duration = end - start; const elapsed = Math.min(now - start, duration); @@ -46,12 +44,12 @@ function updateElapsedAndProgress() { const fill = bar.querySelector(".progress-fill"); if (fill) fill.style.width = `${progress}%`; - }); + } - document.querySelectorAll(".progress-time-labels").forEach((label) => { + for (const label of document.querySelectorAll(".progress-time-labels")) { const start = Number(label.dataset.start); const end = Number(label.dataset.end); - if (!start || !end || end <= start) return; + if (!start || !end || end <= start) continue; const isPaused = now > end; const current = isPaused ? end - start : Math.max(0, now - start); @@ -77,14 +75,14 @@ function updateElapsedAndProgress() { : formatTime(current); } if (totalEl) totalEl.textContent = formatTime(total); - }); + } } updateElapsedAndProgress(); setInterval(updateElapsedAndProgress, 1000); const head = document.querySelector("head"); -let userId = head?.dataset.userId; +const userId = head?.dataset.userId; let instanceUri = head?.dataset.instanceUri; if (userId && instanceUri) { @@ -189,10 +187,7 @@ function buildActivityHTML(activity) { ? `
    ${activity.buttons .map((button, index) => { - const label = - typeof button === "string" - ? button - : button.label; + const label = typeof button === "string" ? button : button.label; let url = null; if (typeof button === "object" && button.url) { url = button.url; @@ -258,9 +253,7 @@ 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", - ); + const mobileIcon = avatarWrapper?.querySelector(".platform-icon.mobile-only"); const userInfo = document.querySelector(".user-info"); const customStatus = userInfo?.querySelector(".custom-status"); diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 6ecab40..17657b3 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -1,6 +1,6 @@ export function timestampToReadable(timestamp?: number): string { const date: Date = - timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); - if (isNaN(date.getTime())) return "Invalid Date"; + 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/ejs.ts b/src/helpers/ejs.ts index 6b03dd0..6544009 100644 --- a/src/helpers/ejs.ts +++ b/src/helpers/ejs.ts @@ -1,5 +1,5 @@ +import { resolve } from "node:path"; import { renderFile } from "ejs"; -import { resolve } from "path"; export async function renderEjsTemplate( viewName: string | string[], diff --git a/src/helpers/lanyard.ts b/src/helpers/lanyard.ts index 258b980..de78955 100644 --- a/src/helpers/lanyard.ts +++ b/src/helpers/lanyard.ts @@ -76,7 +76,7 @@ export async function handleReadMe(data: LanyardData): Promise { return null; if (res.headers.has("content-length")) { - const size: number = parseInt( + const size: number = Number.parseInt( res.headers.get("content-length") || "0", 10, ); diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts index 331be1d..4cbb12b 100644 --- a/src/helpers/logger.ts +++ b/src/helpers/logger.ts @@ -1,15 +1,15 @@ -import { environment } from "@config/environment"; -import { timestampToReadable } from "@helpers/char"; -import type { Stats } from "fs"; +import type { Stats } from "node:fs"; import { + type WriteStream, createWriteStream, existsSync, mkdirSync, statSync, - WriteStream, -} from "fs"; -import { EOL } from "os"; -import { basename, join } from "path"; +} 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; @@ -37,7 +37,7 @@ class Logger { mkdirSync(logDir, { recursive: true }); } - let addSeparator: boolean = false; + let addSeparator = false; if (existsSync(logFile)) { const fileStats: Stats = statSync(logFile); @@ -66,9 +66,9 @@ class Logger { private extractFileName(stack: string): string { const stackLines: string[] = stack.split("\n"); - let callerFile: string = ""; + let callerFile = ""; - for (let i: number = 2; i < stackLines.length; i++) { + for (let i = 2; i < stackLines.length; i++) { const line: string = stackLines[i].trim(); if (line && !line.includes("Logger.") && line.includes("(")) { callerFile = line.split("(")[1]?.split(")")[0] || ""; @@ -91,7 +91,7 @@ class Logger { return { filename, timestamp: readableTimestamp }; } - public info(message: string | string[], breakLine: boolean = false): void { + public info(message: string | string[], breakLine = false): void { const stack: string = new Error().stack || ""; const { filename, timestamp } = this.getCallerInfo(stack); @@ -110,7 +110,7 @@ class Logger { this.writeConsoleMessageColored(logMessageParts, breakLine); } - public warn(message: string | string[], breakLine: boolean = false): void { + public warn(message: string | string[], breakLine = false): void { const stack: string = new Error().stack || ""; const { filename, timestamp } = this.getCallerInfo(stack); @@ -131,7 +131,7 @@ class Logger { public error( message: string | Error | (string | Error)[], - breakLine: boolean = false, + breakLine = false, ): void { const stack: string = new Error().stack || ""; const { filename, timestamp } = this.getCallerInfo(stack); @@ -161,7 +161,7 @@ class Logger { bracketMessage2: string, message: string | string[], color: string, - breakLine: boolean = false, + breakLine = false, ): void { const stack: string = new Error().stack || ""; const { timestamp } = this.getCallerInfo(stack); @@ -189,7 +189,7 @@ class Logger { private writeConsoleMessageColored( logMessageParts: ILogMessageParts, - breakLine: boolean = false, + breakLine = false, ): void { const logMessage: string = Object.keys(logMessageParts) .map((key: string) => { diff --git a/src/index.ts b/src/index.ts index 6d2801d..60606d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,7 @@ import { logger } from "@helpers/logger"; import { serverHandler } from "@/server"; async function main(): Promise { - try { - serverHandler.initialize(); - } catch (error) { - throw error; - } + serverHandler.initialize(); } main().catch((error: Error) => { diff --git a/src/routes/[id].ts b/src/routes/[id].ts index 25d8f30..b970485 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -29,8 +29,7 @@ async function handler(request: ExtendedRequest): Promise { } const presence: LanyardData = data.data; - const readme: string | Promise | null = - await handleReadMe(presence); + const readme: string | Promise | null = await handleReadMe(presence); const ejsTemplateData: EjsTemplateData = { title: `${presence.discord_user.username || "Unknown"}`, diff --git a/src/routes/api/colors.ts b/src/routes/api/colors.ts index d8817ca..9e05bd3 100644 --- a/src/routes/api/colors.ts +++ b/src/routes/api/colors.ts @@ -24,10 +24,7 @@ async function handler(request: ExtendedRequest): Promise { try { res = await fetch(url); } catch { - return Response.json( - { error: "Failed to fetch image" }, - { status: 500 }, - ); + return Response.json({ error: "Failed to fetch image" }, { status: 500 }); } if (!res.ok) { diff --git a/src/routes/index.ts b/src/routes/index.ts index 692fc59..faa9e61 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -28,12 +28,10 @@ async function handler(): Promise { } const presence: LanyardData = data.data; - const readme: string | Promise | null = - await handleReadMe(presence); + const readme: string | Promise | null = await handleReadMe(presence); const ejsTemplateData: EjsTemplateData = { - title: - presence.discord_user.global_name || presence.discord_user.username, + title: presence.discord_user.global_name || presence.discord_user.username, username: presence.discord_user.global_name || presence.discord_user.username, status: presence.discord_status, diff --git a/src/server.ts b/src/server.ts index 2298034..fc8d4b5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { environment } from "@config/environment"; import { logger } from "@helpers/logger"; import { @@ -6,7 +7,6 @@ import { type MatchedRoute, type Serve, } from "bun"; -import { resolve } from "path"; import { webSocketHandler } from "@/websocket"; @@ -77,21 +77,16 @@ class ServerHandler { if (await file.exists()) { const fileContent: ArrayBuffer = await file.arrayBuffer(); - const contentType: string = - file.type || "application/octet-stream"; + const contentType: string = file.type || "application/octet-stream"; return new Response(fileContent, { headers: { "Content-Type": contentType }, }); - } else { - logger.warn(`File not found: ${filePath}`); - return 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, - ]); + logger.error([`Error serving static file: ${pathname}`, error as Error]); return new Response("Internal Server Error", { status: 500 }); } } @@ -117,8 +112,7 @@ class ServerHandler { try { const routeModule: RouteModule = await import(filePath); - const contentType: string | null = - request.headers.get("Content-Type"); + const contentType: string | null = request.headers.get("Content-Type"); const actualContentType: string | null = contentType ? contentType.split(";")[0].trim() : null; @@ -145,9 +139,7 @@ class ServerHandler { if ( (Array.isArray(routeModule.routeDef.method) && - !routeModule.routeDef.method.includes( - request.method, - )) || + !routeModule.routeDef.method.includes(request.method)) || (!Array.isArray(routeModule.routeDef.method) && routeModule.routeDef.method !== request.method) ) { @@ -172,9 +164,7 @@ class ServerHandler { if (Array.isArray(expectedContentType)) { matchesAccepts = expectedContentType.includes("*/*") || - expectedContentType.includes( - actualContentType || "", - ); + expectedContentType.includes(actualContentType || ""); } else { matchesAccepts = expectedContentType === "*/*" || @@ -213,10 +203,7 @@ class ServerHandler { } } } catch (error: unknown) { - logger.error([ - `Error handling route ${request.url}:`, - error as Error, - ]); + logger.error([`Error handling route ${request.url}:`, error as Error]); response = Response.json( { diff --git a/src/websocket.ts b/src/websocket.ts index ce87fe8..99686e8 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,5 +1,5 @@ import { logger } from "@helpers/logger"; -import { type ServerWebSocket } from "bun"; +import type { ServerWebSocket } from "bun"; class WebSocketHandler { public handleMessage(ws: ServerWebSocket, message: string): void { @@ -20,11 +20,7 @@ class WebSocketHandler { } } - public handleClose( - ws: ServerWebSocket, - code: number, - reason: string, - ): void { + public handleClose(ws: ServerWebSocket, code: number, reason: string): void { logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`); } } diff --git a/tsconfig.json b/tsconfig.json index ac5f2c7..68a5a97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,28 +2,14 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": [ - "src/*" - ], - "@config/*": [ - "config/*" - ], - "@types/*": [ - "types/*" - ], - "@helpers/*": [ - "src/helpers/*" - ] + "@/*": ["src/*"], + "@config/*": ["config/*"], + "@types/*": ["types/*"], + "@helpers/*": ["src/helpers/*"] }, - "typeRoots": [ - "./src/types", - "./node_modules/@types" - ], + "typeRoots": ["./src/types", "./node_modules/@types"], // Enable latest features - "lib": [ - "ESNext", - "DOM" - ], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", @@ -41,11 +27,7 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false, + "noPropertyAccessFromIndexSignature": false }, - "include": [ - "src", - "types", - "config" - ], + "include": ["src", "types", "config"] } From 66744ddd1094851614ef88558416f056afb65293 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 7 Apr 2025 18:58:54 -0400 Subject: [PATCH 04/85] move all colors to :root, add activity small image and hover text, add support for streaming indicator, https://git.creations.works/creations/profilePage/issues/2 --- public/css/index.css | 150 ++++++++++++++++++++++++++++++++----------- public/js/index.js | 60 +++++++++++++---- src/routes/[id].ts | 14 +++- src/routes/index.ts | 9 ++- src/views/index.ejs | 39 +++++++---- 5 files changed, 205 insertions(+), 67 deletions(-) diff --git a/public/css/index.css b/public/css/index.css index 59e3a37..7a1a288 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,7 +1,38 @@ +:root { + --background: #0e0e10; + --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; + + --status-online: #23a55a; + --status-idle: #f0b232; + --status-dnd: #e03e3e; + --status-offline: #747f8d; + --status-streaming: #b700ff; + + --progress-bg: #f23f43; + --progress-fill: #5865f2; + + --button-bg: #5865f2; + --button-hover-bg: #4752c4; + --button-disabled-bg: #2d2e31; + + --blockquote-color: #aaa; + --code-bg: #2e2e30; + + --readme-bg: #1a1a1d; +} + body { font-family: system-ui, sans-serif; - background-color: #0e0e10; - color: #ffffff; + background-color: var(--background); + color: var(--text-color); margin: 0; padding: 2rem; display: flex; @@ -52,26 +83,30 @@ body { width: 24px; height: 24px; border-radius: 50%; - border: 4px solid #0e0e10; + border: 4px solid var(--background); display: flex; align-items: center; justify-content: center; } .status-indicator.online { - background-color: #23a55a; + background-color: var(--status-online); } .status-indicator.idle { - background-color: #f0b232; + background-color: var(--status-idle); } .status-indicator.dnd { - background-color: #f23f43; + background-color: var(--status-dnd); } .status-indicator.offline { - background-color: #747f8d; + background-color: var(--status-offline); +} + +.status-indicator.streaming { + background-color: var(--status-streaming); } .platform-icon.mobile-only { @@ -91,12 +126,12 @@ body { h1 { font-size: 2.5rem; margin: 0; - color: #00b0f4; + color: var(--link-color); } .custom-status { font-size: 1.2rem; - color: #bbb; + color: var(--text-subtle); margin-top: 0.25rem; word-break: break-word; overflow-wrap: anywhere; @@ -141,14 +176,14 @@ ul { display: flex; flex-direction: row; gap: 1rem; - background-color: #1e1f22; + background-color: var(--card-bg); padding: 0.75rem 1rem; border-radius: 10px; - box-shadow: 0 1px 0 0 #2e2e30; + border: 1px solid var(--border-color); } .activity:hover { - background: #2a2a2d; + background: var(--card-hover-bg); } .activity-wrapper { @@ -163,7 +198,28 @@ ul { gap: 1rem; } -.activity-art { +.activity-image-wrapper { + position: relative; + width: 80px; + height: 80px; +} + +.activity-image-small { + width: 25px; + height: 25px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border-color: var(--card-bg); + border-width: 2px; + border-style: solid; + + position: absolute; + bottom: -6px; + right: -10px; +} + +.activity-image { width: 80px; height: 80px; border-radius: 6px; @@ -201,17 +257,17 @@ ul { .activity-name { font-weight: 600; font-size: 1rem; - color: #fff; + color: var(--text-color); } .activity-detail { font-size: 0.875rem; - color: #b5bac1; + color: var(--text-secondary); } .activity-timestamp { font-size: 0.75rem; - color: #b5bac1; + color: var(--text-secondary); text-align: right; margin-left: auto; white-space: nowrap; @@ -219,14 +275,14 @@ ul { .progress-bar { height: 4px; - background-color: #2e2e30; + background-color: var(--border-color); border-radius: 2px; - margin-top: 0.5rem; + margin-top: 1rem; overflow: hidden; } .progress-fill { - background-color: #5865f2; + background-color: var(--progress-fill); transition: width 0.4s ease; height: 100%; } @@ -234,14 +290,13 @@ ul { .progress-bar, .progress-time-labels { width: 100%; - margin-top: 0.5rem; } .progress-time-labels { display: flex; justify-content: space-between; font-size: 0.75rem; - color: #888; + color: var(--text-muted); margin-top: 0.25rem; } @@ -255,7 +310,7 @@ ul { font-size: 0.75rem; text-transform: uppercase; font-weight: 600; - color: #aaa; + color: var(--blockquote-color); margin-bottom: 0.50rem; display: block; } @@ -266,7 +321,7 @@ ul { .progress-time-labels.paused .progress-current::after { content: " ⏸"; - color: #f0b232; + color: var(--status-idle); } .activity-buttons { @@ -277,7 +332,7 @@ ul { } .activity-button { - background-color: #5865f2; + background-color: var(--progress-fill); color: white; border: none; border-radius: 3px; @@ -290,12 +345,12 @@ ul { } .activity-button:hover { - background-color: #4752c4; + background-color: var(--button-hover-bg); text-decoration: none; } .activity-button:disabled { - background-color: #2d2e31; + background-color: var(--button-disabled-bg); cursor: not-allowed; opacity: 0.8; } @@ -324,6 +379,13 @@ ul { width: 100%; } + .activity-image-wrapper { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + } + .avatar-wrapper { width: 96px; height: 96px; @@ -381,18 +443,28 @@ ul { border-radius: 0; } - .activity-art { + .activity-image { width: 100%; - max-width: 300px; + max-width: 100%; height: auto; border-radius: 8px; } + .activity-image-small { + width: 40px; + height: 40px; + } + .activity-content { width: 100%; align-items: center; } + .activity-wrapper-inner { + flex-direction: column; + align-items: center; + } + .activity-header { flex-direction: column; align-items: center; @@ -429,12 +501,12 @@ ul { /* readme :p */ .readme { - max-width: 600px; + max-width: 700px; width: 100%; - background: #1a1a1d; + background: var(--readme-bg); padding: 1.5rem; border-radius: 8px; - box-shadow: 0 0 0 1px #2e2e30; + border: 1px solid var(--border-color); margin-top: 2rem; @@ -445,13 +517,13 @@ ul { .readme h2 { margin-top: 0; - color: #00b0f4; + color: var(--link-color); } .markdown-body { font-size: 1rem; line-height: 1.6; - color: #ddd; + color: var(--text-color); } .markdown-body h1, @@ -460,7 +532,7 @@ ul { .markdown-body h4, .markdown-body h5, .markdown-body h6 { - color: #ffffff; + color: var(--text-color); margin-top: 1.25rem; margin-bottom: 0.5rem; } @@ -470,7 +542,7 @@ ul { } .markdown-body a { - color: #00b0f4; + color: var(--link-color); text-decoration: none; } @@ -479,7 +551,7 @@ ul { } .markdown-body code { - background: #2e2e30; + background: var(--border-color); padding: 0.2em 0.4em; border-radius: 4px; font-family: monospace; @@ -487,7 +559,7 @@ ul { } .markdown-body pre { - background: #2e2e30; + background: var(--border-color); padding: 1rem; border-radius: 6px; overflow-x: auto; @@ -502,9 +574,9 @@ ul { } .markdown-body blockquote { - border-left: 4px solid #00b0f4; + border-left: 4px solid var(--link-color); padding-left: 1rem; - color: #aaa; + color: var(--blockquote-color); margin: 1rem 0; } diff --git a/public/js/index.js b/public/js/index.js index 913773e..e6a9afe 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -130,6 +130,25 @@ if (userId && instanceUri) { }); } +function resolveActivityImage(img, applicationId) { + if (!img) return null; + + if (img.startsWith("mp:external/")) { + return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; + } + + if (img.includes("/https/")) { + const clean = img.split("/https/")[1]; + return clean ? `https://${clean}` : null; + } + + if (img.startsWith("spotify:")) { + return `https://i.scdn.co/image/${img.split(":")[1]}`; + } + + return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; +} + function buildActivityHTML(activity) { const start = activity.timestamps?.start; const end = activity.timestamps?.end; @@ -141,18 +160,18 @@ function buildActivityHTML(activity) { ? Math.min(100, Math.floor((elapsed / total) * 100)) : null; - const img = activity.assets?.large_image; let art = null; + let smallArt = null; - if (img?.startsWith("mp:external/")) { - art = `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; - } else if (img?.includes("/https/")) { - const clean = img.split("/https/")[1]; - if (clean) art = `https://${clean}`; - } else if (img?.startsWith("spotify:")) { - art = `https://i.scdn.co/image/${img.split(":")[1]}`; - } else if (img) { - art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; + if (activity.assets) { + art = resolveActivityImage( + activity.assets.large_image, + activity.application_id, + ); + smallArt = resolveActivityImage( + activity.assets.small_image, + activity.application_id, + ); } const activityTypeMap = { @@ -220,6 +239,13 @@ 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` : ""} +
    ` + : ""; + return `
  • @@ -228,7 +254,7 @@ function buildActivityHTML(activity) { ${activityTimestamp}
    - ${art ? `Art` : ""} + ${activityArt}
    @@ -264,8 +290,16 @@ function updatePresence(data) { desktop: data.active_on_discord_desktop, }; + let status = "offline"; + console.log(data.activities.some((activity) => activity.type === 1)); + if (data.activities.some((activity) => activity.type === 1)) { + status = "streaming"; + } else { + status = data.discord_status; + } + if (statusIndicator) { - statusIndicator.className = `status-indicator ${data.discord_status}`; + statusIndicator.className = `status-indicator ${status}`; } if (platform.mobile && !mobileIcon) { @@ -276,7 +310,7 @@ function updatePresence(data) { `; } else if (!platform.mobile && mobileIcon) { mobileIcon.remove(); - avatarWrapper.innerHTML += `
    `; + avatarWrapper.innerHTML += `
    `; } const custom = data.activities?.find((a) => a.type === 4); diff --git a/src/routes/[id].ts b/src/routes/[id].ts index b970485..bdfc975 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -31,10 +31,18 @@ async function handler(request: ExtendedRequest): Promise { 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; + } + const ejsTemplateData: EjsTemplateData = { - title: `${presence.discord_user.username || "Unknown"}`, - username: presence.discord_user.username, - status: presence.discord_status, + 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: { diff --git a/src/routes/index.ts b/src/routes/index.ts index faa9e61..829d105 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -30,11 +30,18 @@ async function handler(): Promise { 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; + } + const ejsTemplateData: EjsTemplateData = { title: presence.discord_user.global_name || presence.discord_user.username, username: presence.discord_user.global_name || presence.discord_user.username, - status: presence.discord_status, + status: status, activities: presence.activities, user: presence.discord_user, platform: { diff --git a/src/views/index.ejs b/src/views/index.ejs index 4b1e1bb..0611579 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -76,20 +76,32 @@ const total = (start && end) ? end - start : null; const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null; - const img = activity.assets?.large_image; let art = null; + let smallArt = null; - if (img?.startsWith("mp:external/")) { - art = `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; - } else if (img?.includes("/https/")) { - const clean = img.split("/https/")[1]; - if (clean) art = `https://${clean}`; - } else if (img?.startsWith("spotify:")) { - art = `https://i.scdn.co/image/${img.split(":")[1]}`; - } else if (img) { - art = `https://cdn.discordapp.com/app-assets/${activity.application_id}/${img}.png`; + 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", @@ -124,7 +136,12 @@
    <% if (art) { %> - Art +
    + Art> + <% if (smallArt) { %> + Small Art> + <% } %> +
    <% } %>
    From 78c2eb454587f31a051ad996954fb0cc148f0e3c Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 9 Apr 2025 18:23:52 -0400 Subject: [PATCH 05/85] add rain and snow kv options fix issue with activity header not showing when no initial activity --- public/css/index.css | 24 ++++ public/js/index.js | 9 +- public/js/rain.js | 77 +++++++++++++ public/js/snow.js | 84 ++++++++++++++ src/routes/[id].ts | 2 + src/routes/index.ts | 2 + src/views/index.ejs | 254 +++++++++++++++++++++---------------------- 7 files changed, 319 insertions(+), 133 deletions(-) create mode 100644 public/js/rain.js create mode 100644 public/js/snow.js diff --git a/public/css/index.css b/public/css/index.css index 7a1a288..1d8ad00 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -40,6 +40,30 @@ body { align-items: center; } +.snowflake { + position: absolute; + background-color: white; + border-radius: 50%; + pointer-events: none; + z-index: 1; +} + +.raindrop { + position: absolute; + background-color: white; + border-radius: 50%; + pointer-events: none; + z-index: 1; +} + +.hidden { + display: none; +} + +.activity-header.hidden { + display: none; +} + .user-card { display: flex; flex-direction: column; diff --git a/public/js/index.js b/public/js/index.js index e6a9afe..22e2dc6 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -291,7 +291,6 @@ function updatePresence(data) { }; let status = "offline"; - console.log(data.activities.some((activity) => activity.type === 1)); if (data.activities.some((activity) => activity.type === 1)) { status = "streaming"; } else { @@ -339,11 +338,15 @@ function updatePresence(data) { }); const activityList = document.querySelector(".activities"); + const activitiesTitle = document.querySelector(".activity-header"); - if (activityList) { - activityList.innerHTML = ""; + if (activityList && activitiesTitle) { if (filtered?.length) { activityList.innerHTML = filtered.map(buildActivityHTML).join(""); + activitiesTitle.classList.remove("hidden"); + } else { + activityList.innerHTML = ""; + activitiesTitle.classList.add("hidden"); } updateElapsedAndProgress(); } diff --git a/public/js/rain.js b/public/js/rain.js new file mode 100644 index 0000000..27c0d8f --- /dev/null +++ b/public/js/rain.js @@ -0,0 +1,77 @@ +const rainContainer = document.createElement("div"); +rainContainer.style.position = "fixed"; +rainContainer.style.top = "0"; +rainContainer.style.left = "0"; +rainContainer.style.width = "100vw"; +rainContainer.style.height = "100vh"; +rainContainer.style.pointerEvents = "none"; +document.body.appendChild(rainContainer); + +const maxRaindrops = 100; +const raindrops = []; +const mouse = { x: -100, y: -100 }; + +document.addEventListener("mousemove", (e) => { + mouse.x = e.clientX; + mouse.y = e.clientY; +}); + +const getRaindropColor = () => { + const htmlTag = document.documentElement; + return htmlTag.getAttribute("data-theme") === "dark" + ? "rgba(173, 216, 230, 0.8)" + : "rgba(70, 130, 180, 0.8)"; +}; + +const createRaindrop = () => { + if (raindrops.length >= maxRaindrops) { + const oldestRaindrop = raindrops.shift(); + rainContainer.removeChild(oldestRaindrop); + } + + const raindrop = document.createElement("div"); + raindrop.classList.add("raindrop"); + raindrop.style.position = "absolute"; + raindrop.style.width = "2px"; + raindrop.style.height = `${Math.random() * 10 + 10}px`; + raindrop.style.background = getRaindropColor(); + raindrop.style.borderRadius = "1px"; + raindrop.style.left = `${Math.random() * window.innerWidth}px`; + raindrop.style.top = `-${raindrop.style.height}`; + raindrop.style.opacity = Math.random() * 0.5 + 0.3; + raindrop.speed = Math.random() * 6 + 4; + raindrop.directionX = (Math.random() - 0.5) * 0.2; + raindrop.directionY = Math.random() * 0.5 + 0.8; + + raindrops.push(raindrop); + rainContainer.appendChild(raindrop); +}; + +setInterval(createRaindrop, 50); + +function updateRaindrops() { + raindrops.forEach((raindrop, index) => { + const rect = raindrop.getBoundingClientRect(); + + raindrop.style.left = `${rect.left + raindrop.directionX * raindrop.speed}px`; + raindrop.style.top = `${rect.top + raindrop.directionY * raindrop.speed}px`; + + if (rect.top + rect.height >= window.innerHeight) { + rainContainer.removeChild(raindrop); + raindrops.splice(index, 1); + } + + if ( + rect.left > window.innerWidth || + rect.top > window.innerHeight || + rect.left < 0 + ) { + raindrop.style.left = `${Math.random() * window.innerWidth}px`; + raindrop.style.top = `-${raindrop.style.height}`; + } + }); + + requestAnimationFrame(updateRaindrops); +} + +updateRaindrops(); diff --git a/public/js/snow.js b/public/js/snow.js new file mode 100644 index 0000000..05048a8 --- /dev/null +++ b/public/js/snow.js @@ -0,0 +1,84 @@ +document.addEventListener("DOMContentLoaded", () => { + const snowContainer = document.createElement("div"); + snowContainer.style.position = "fixed"; + snowContainer.style.top = "0"; + snowContainer.style.left = "0"; + snowContainer.style.width = "100vw"; + snowContainer.style.height = "100vh"; + snowContainer.style.pointerEvents = "none"; + document.body.appendChild(snowContainer); + + const maxSnowflakes = 60; + const snowflakes = []; + const mouse = { x: -100, y: -100 }; + + document.addEventListener("mousemove", (e) => { + mouse.x = e.clientX; + mouse.y = e.clientY; + }); + + const createSnowflake = () => { + if (snowflakes.length >= maxSnowflakes) { + const oldestSnowflake = snowflakes.shift(); + snowContainer.removeChild(oldestSnowflake); + } + + const snowflake = document.createElement("div"); + snowflake.classList.add("snowflake"); + snowflake.style.position = "absolute"; + snowflake.style.width = `${Math.random() * 3 + 2}px`; + snowflake.style.height = snowflake.style.width; + snowflake.style.background = "white"; + snowflake.style.borderRadius = "50%"; + snowflake.style.opacity = Math.random(); + snowflake.style.left = `${Math.random() * window.innerWidth}px`; + snowflake.style.top = `-${snowflake.style.height}`; + snowflake.speed = Math.random() * 3 + 2; + snowflake.directionX = (Math.random() - 0.5) * 0.5; + snowflake.directionY = Math.random() * 0.5 + 0.5; + + snowflakes.push(snowflake); + snowContainer.appendChild(snowflake); + }; + + setInterval(createSnowflake, 80); + + function updateSnowflakes() { + snowflakes.forEach((snowflake, index) => { + const rect = snowflake.getBoundingClientRect(); + + const dx = rect.left + rect.width / 2 - mouse.x; + const dy = rect.top + rect.height / 2 - mouse.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 30) { + snowflake.directionX += (dx / distance) * 0.02; + snowflake.directionY += (dy / distance) * 0.02; + } else { + snowflake.directionX += (Math.random() - 0.5) * 0.01; + snowflake.directionY += (Math.random() - 0.5) * 0.01; + } + + snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`; + snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`; + + if (rect.top + rect.height >= window.innerHeight) { + snowContainer.removeChild(snowflake); + snowflakes.splice(index, 1); + } + + if ( + rect.left > window.innerWidth || + rect.top > window.innerHeight || + rect.left < 0 + ) { + snowflake.style.left = `${Math.random() * window.innerWidth}px`; + snowflake.style.top = `-${snowflake.style.height}`; + } + }); + + requestAnimationFrame(updateSnowflakes); + } + + updateSnowflakes(); +}); diff --git a/src/routes/[id].ts b/src/routes/[id].ts index bdfc975..16ea3c0 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -52,6 +52,8 @@ async function handler(request: ExtendedRequest): Promise { }, instance, readme, + allowSnow: presence.kv.snow || false, + allowRain: presence.kv.rain || false, }; return await renderEjsTemplate("index", ejsTemplateData); diff --git a/src/routes/index.ts b/src/routes/index.ts index 829d105..7f12f0a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -51,6 +51,8 @@ async function handler(): Promise { }, instance, readme, + allowSnow: presence.kv.snow || false, + allowRain: presence.kv.rain || false, }; return await renderEjsTemplate("index", ejsTemplateData); diff --git a/src/views/index.ejs b/src/views/index.ejs index 0611579..d4f2be3 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -13,6 +13,13 @@ + <% if (allowSnow) { %> + + <% } %> + <% if(allowRain) { %> + + <% } %> + @@ -59,152 +66,139 @@ let filtered = activities .filter(a => a.type !== 4) .sort((a, b) => { - const priority = { 2: 0, 1: 1, 3: 2 }; // Listening, Streaming, Watching ? should i keep this + const priority = { 2: 0, 1: 1, 3: 2 }; const aPriority = priority[a.type] ?? 99; const bPriority = priority[b.type] ?? 99; return aPriority - bPriority; }); %> - <% if (filtered.length > 0) { %> -

    Activities

    -
      - <% filtered.forEach(activity => { - const start = activity.timestamps?.start; - const end = activity.timestamps?.end; - const now = Date.now(); - const elapsed = start ? now - start : 0; - const total = (start && end) ? end - start : null; - const progress = (total && elapsed > 0) ? Math.min(100, Math.floor((elapsed / total) * 100)) : null; - let art = null; - let smallArt = null; +

      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; - function resolveActivityImage(img, applicationId) { - if (!img) return null; + let art = null; + let smallArt = null; - if (img.startsWith("mp:external/")) { - return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; - } - - if (img.includes("/https/")) { - const clean = img.split("/https/")[1]; - return clean ? `https://${clean}` : null; - } - - if (img.startsWith("spotify:")) { - return `https://i.scdn.co/image/${img.split(":")[1]}`; - } - - return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; + function resolveActivityImage(img, applicationId) { + if (!img) return null; + if (img.startsWith("mp:external/")) { + return `https://media.discordapp.net/external/${img.slice("mp:external/".length)}`; } - - if (activity.assets) { - art = resolveActivityImage(activity.assets.large_image, activity.application_id); - smallArt = resolveActivityImage(activity.assets.small_image, activity.application_id); + if (img.includes("/https/")) { + const clean = img.split("/https/")[1]; + 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`; + } - const activityTypeMap = { - 0: "Playing", - 1: "Streaming", - 2: "Listening", - 3: "Watching", - 4: "Custom Status", - 5: "Competing", - }; + if (activity.assets) { + art = resolveActivityImage(activity.assets.large_image, activity.application_id); + smallArt = resolveActivityImage(activity.assets.small_image, activity.application_id); + } - 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' }) %> - -
        - <% } %> -
        + const activityTypeMap = { + 0: "Playing", + 1: "Streaming", + 2: "Listening", + 3: "Watching", + 4: "Custom Status", + 5: "Competing", + }; -
        - <% 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 %> - <% } %> - <% }); %> -
        - <% } %> -
        -
        + 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 (progress !== null) { %> -
        -
        >
        -
        - - <% if (start && end) { %> -
        - - <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %> -
        - <% } %> <% } %>
        -
      • - <% }) %> -
      - <% } %> +
      + <% 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) { %>
    From c54d959e7e97c38cef9048e3b581d6a93271ae8e Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 9 Apr 2025 18:35:08 -0400 Subject: [PATCH 06/85] fix issue with rain and snow, fix ejs formatting --- src/routes/index.ts | 9 ++- src/views/index.ejs | 152 +++++++++++++++++++++++--------------------- 2 files changed, 85 insertions(+), 76 deletions(-) diff --git a/src/routes/index.ts b/src/routes/index.ts index 7f12f0a..d4c8d97 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -37,6 +37,8 @@ async function handler(): Promise { status = presence.discord_status; } + console.log(presence.kv.rain); + const ejsTemplateData: EjsTemplateData = { title: presence.discord_user.global_name || presence.discord_user.username, username: @@ -51,10 +53,13 @@ async function handler(): Promise { }, instance, readme, - allowSnow: presence.kv.snow || false, - allowRain: presence.kv.rain || false, + allowSnow: presence.kv.snow === "true", + allowRain: presence.kv.rain === "true", }; + console.log("allowSnow", presence.kv.snow); + console.log("allowRain", presence.kv.rain); + return await renderEjsTemplate("index", ejsTemplateData); } diff --git a/src/views/index.ejs b/src/views/index.ejs index d4f2be3..2d15947 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -1,5 +1,6 @@ + @@ -13,29 +14,31 @@ + <%= typeof allowSnow %> <%= allowSnow %> <% if (allowSnow) { %> - + <% } %> <% if(allowRain) { %> - + <% } %> +
    Avatar <% if (user.avatar_decoration_data) { %> - Decoration + Decoration <% } %> <% if (platform.mobile) { %> - - - + + + <% } else { %> -
    +
    <% } %>
    @@ -121,51 +124,51 @@ ? "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' }) %> - -
    +
  • +
    +
    + <%= 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> <% } %>
    -
    - <% 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 %>
    - <% } %> +
    +
    + <%= primaryLine %>
    -
    - <% if (activity.buttons && activity.buttons.length > 0) { %> -
    - <% activity.buttons.forEach((button, index) => { + <% 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) { @@ -174,36 +177,37 @@ buttonUrl = activity.url; } %> - <% if (buttonUrl) { %> - <%= buttonLabel %> - <% } %> - <% }) %> -
    + <% 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 (progress !== null) { %> +
    +
    >
    +
    + <% if (start && end) { %> +
    + + <%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %> +
    + <% } %> + <% } %> +
    + <% }); %> <% if (readme) { %> -
    -
    <%- readme %>
    -
    +
    +
    <%- readme %>
    +
    <% } %> + From 5e94af59803397b9ce07f07c84fd120a0d3ef660 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 9 Apr 2025 18:38:24 -0400 Subject: [PATCH 07/85] not me forgetting console logs --- src/routes/index.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/routes/index.ts b/src/routes/index.ts index d4c8d97..a15fa81 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -37,8 +37,6 @@ async function handler(): Promise { status = presence.discord_status; } - console.log(presence.kv.rain); - const ejsTemplateData: EjsTemplateData = { title: presence.discord_user.global_name || presence.discord_user.username, username: @@ -57,9 +55,6 @@ async function handler(): Promise { allowRain: presence.kv.rain === "true", }; - console.log("allowSnow", presence.kv.snow); - console.log("allowRain", presence.kv.rain); - return await renderEjsTemplate("index", ejsTemplateData); } From f4aeb7aafb8fa74c3d2a93cfbf841bc47cf61648 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 9 Apr 2025 18:41:16 -0400 Subject: [PATCH 08/85] this is why i need a dev branch --- src/views/index.ejs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/views/index.ejs b/src/views/index.ejs index 2d15947..77fadd4 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -14,7 +14,6 @@ - <%= typeof allowSnow %> <%= allowSnow %> <% if (allowSnow) { %> <% } %> From 30e9057ba87f950e356037cbce66f466892843af Mon Sep 17 00:00:00 2001 From: creations Date: Thu, 10 Apr 2025 06:29:21 -0400 Subject: [PATCH 09/85] update biome.json and add workflow --- .forgejo/workflows/biomejs.yml | 24 ++++++++++++++++++++++++ biome.json | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .forgejo/workflows/biomejs.yml diff --git a/.forgejo/workflows/biomejs.yml b/.forgejo/workflows/biomejs.yml new file mode 100644 index 0000000..15c990c --- /dev/null +++ b/.forgejo/workflows/biomejs.yml @@ -0,0 +1,24 @@ +name: Code quality checks + +on: + push: + pull_request: + +jobs: + biome: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + echo "$BUN_INSTALL/bin" >> $GITHUB_PATH + + - name: Install Dependencies + run: bun install + + - name: Run Biome with verbose output + run: bunx biome ci . --verbose diff --git a/biome.json b/biome.json index 3c4d4f1..921a7a5 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,7 @@ "useIgnoreFile": false }, "files": { - "ignoreUnknown": false, + "ignoreUnknown": true, "ignore": [] }, "formatter": { From ff0ece96266d3fbda93a5270b5ca77475174124f Mon Sep 17 00:00:00 2001 From: creations Date: Thu, 10 Apr 2025 07:09:10 -0400 Subject: [PATCH 10/85] add option to use vibrant colors from avatar needs moving around --- biome.json | 6 ++++ public/css/index.css | 31 -------------------- src/helpers/colors.ts | 49 ++++++++++++++++++++++++++++++++ src/routes/api/colors.ts | 56 ++++++++++--------------------------- src/routes/index.ts | 10 +++++++ src/views/index.ejs | 2 ++ src/views/partial/style.ejs | 32 +++++++++++++++++++++ types/routes.d.ts | 7 +++++ 8 files changed, 120 insertions(+), 73 deletions(-) create mode 100644 src/helpers/colors.ts create mode 100644 src/views/partial/style.ejs diff --git a/biome.json b/biome.json index 921a7a5..fa06b32 100644 --- a/biome.json +++ b/biome.json @@ -17,6 +17,12 @@ "organizeImports": { "enabled": true }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, "linter": { "enabled": true, "rules": { diff --git a/public/css/index.css b/public/css/index.css index 1d8ad00..ac5f70f 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,34 +1,3 @@ -:root { - --background: #0e0e10; - --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; - - --status-online: #23a55a; - --status-idle: #f0b232; - --status-dnd: #e03e3e; - --status-offline: #747f8d; - --status-streaming: #b700ff; - - --progress-bg: #f23f43; - --progress-fill: #5865f2; - - --button-bg: #5865f2; - --button-hover-bg: #4752c4; - --button-disabled-bg: #2d2e31; - - --blockquote-color: #aaa; - --code-bg: #2e2e30; - - --readme-bg: #1a1a1d; -} - body { font-family: system-ui, sans-serif; background-color: var(--background); diff --git a/src/helpers/colors.ts b/src/helpers/colors.ts new file mode 100644 index 0000000..43a74e7 --- /dev/null +++ b/src/helpers/colors.ts @@ -0,0 +1,49 @@ +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/routes/api/colors.ts b/src/routes/api/colors.ts index 9e05bd3..ef97012 100644 --- a/src/routes/api/colors.ts +++ b/src/routes/api/colors.ts @@ -1,7 +1,4 @@ -import { fetch } from "bun"; -import { Vibrant } from "node-vibrant/node"; - -type Palette = Awaited>; +import { getImageColors } from "@helpers/colors"; const routeDef: RouteDef = { method: "GET", @@ -12,46 +9,21 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { url } = request.query; - if (!url) { - return Response.json({ error: "URL is required" }, { status: 400 }); + 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": "*", + }, + }); } - if (typeof url !== "string" || !url.startsWith("http")) { - return Response.json({ error: "Invalid URL" }, { status: 400 }); - } - - let res: Response; - try { - res = await fetch(url); - } catch { - return Response.json({ error: "Failed to fetch image" }, { status: 500 }); - } - - if (!res.ok) { - return Response.json( - { error: "Image fetch returned error" }, - { status: res.status }, - ); - } - - const type: string | null = res.headers.get("content-type"); - if (!type?.startsWith("image/")) { - return Response.json({ error: "Not an image" }, { status: 400 }); - } - - const buffer: Buffer = Buffer.from(await res.arrayBuffer()); - const base64: string = buffer.toString("base64"); - const colors: Palette = await Vibrant.from(buffer).getPalette(); - - const payload: { - img: string; - colors: Palette; - } = { - img: `data:${type};base64,${base64}`, - colors, - }; - - const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(payload)); + const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(result)); return new Response(compressed, { headers: { diff --git a/src/routes/index.ts b/src/routes/index.ts index a15fa81..b0d6a61 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,4 @@ +import { getImageColors } from "@/helpers/colors"; import { lanyardConfig } from "@config/environment"; import { renderEjsTemplate } from "@helpers/ejs"; import { getLanyardData, handleReadMe } from "@helpers/lanyard"; @@ -37,6 +38,14 @@ async function handler(): Promise { 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: @@ -53,6 +62,7 @@ async function handler(): Promise { readme, allowSnow: presence.kv.snow === "true", allowRain: presence.kv.rain === "true", + colors: colors?.colors ?? {}, }; return await renderEjsTemplate("index", ejsTemplateData); diff --git a/src/views/index.ejs b/src/views/index.ejs index 77fadd4..0db4f4b 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -24,6 +24,8 @@ +<%- include('partial/style.ejs') %> +
    diff --git a/src/views/partial/style.ejs b/src/views/partial/style.ejs new file mode 100644 index 0000000..2f50cdf --- /dev/null +++ b/src/views/partial/style.ejs @@ -0,0 +1,32 @@ + diff --git a/types/routes.d.ts b/types/routes.d.ts index 9d9d809..6af6a4b 100644 --- a/types/routes.d.ts +++ b/types/routes.d.ts @@ -13,3 +13,10 @@ type RouteModule = { ) => Promise | Response; routeDef: RouteDef; }; + +type Palette = Awaited>; +type Swatch = Awaited>; +type ImageColorResult = { + img: string; + colors: Palette | Record; +}; From 0d5fbe76b7a712bdf03280feda5acffb93d940b5 Mon Sep 17 00:00:00 2001 From: creations Date: Thu, 10 Apr 2025 07:09:27 -0400 Subject: [PATCH 11/85] forgot comment --- src/views/partial/style.ejs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/views/partial/style.ejs b/src/views/partial/style.ejs index 2f50cdf..cccf3a2 100644 --- a/src/views/partial/style.ejs +++ b/src/views/partial/style.ejs @@ -1,6 +1,5 @@ + + + + + + + + \ No newline at end of file diff --git a/public/css/index.css b/public/css/index.css index 4728251..623c429 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -52,6 +52,26 @@ body { align-items: center; } +.open-source-logo { + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + cursor: pointer; + + position: fixed; + bottom: 1rem; + right: .5rem; + z-index: 1000; + + opacity: 0.5; + transition: opacity 0.3s ease; +} + +.open-source-logo:hover { + opacity: 1; +} + .hidden { display: none !important; } @@ -493,6 +513,7 @@ ul { border: none; background-color: transparent; margin-top: 0; + box-shadow: none; } .avatar-status-wrapper { diff --git a/src/routes/api/art[game].ts b/src/routes/api/art[game].ts index 05aa342..006337d 100644 --- a/src/routes/api/art[game].ts +++ b/src/routes/api/art[game].ts @@ -1,11 +1,6 @@ import { redisTtl, steamGridDbKey } from "@config/environment"; -import { logger } from "@helpers/logger"; import { redis } from "bun"; -if (!steamGridDbKey) { - logger.warn("[SteamGridDB] Route disabled: Missing API key"); -} - const routeDef: RouteDef = { method: "GET", accepts: "*/*", diff --git a/src/views/index.ejs b/src/views/index.ejs index d704836..aced41f 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -34,6 +34,9 @@ <%- include("partial/style.ejs") %> + + +
    From 91c8e341e8301e55a9fdd2ae61491b3143082647 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 21 Apr 2025 18:10:20 -0400 Subject: [PATCH 29/85] forgot console log --- public/js/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index a734d1f..0181d5c 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -435,8 +435,6 @@ async function getAllNoAsset() { "img.activity-image.no-asset", ); - console.log("Images with .no-asset:", noAssetImages.length, noAssetImages); - for (const img of noAssetImages) { const name = img.dataset.name; if (!name) continue; From d15b69fe38934476fc375ec6e7ac6210a2e8928f Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 21 Apr 2025 18:26:17 -0400 Subject: [PATCH 30/85] fix readme --- README.md | 78 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index aa09058..396d1b3 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,32 @@ A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS. +--- + ## Requirements -This project relies on the following services to function correctly: +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 for Discord presence data. -Make sure Lanyard is running and accessible before using this profile page. +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 for caching third-party data (e.g., SteamGridDB icons). -We recommend using [Dragonfly](https://www.dragonflydb.io/) as a high-performance drop-in replacement for Redis. +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 + +>You only have to use this if you want to fetch game icons that Discord doesn’t provide: +https://www.steamgriddb.com/api/v2 --- @@ -36,40 +49,39 @@ 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`) | -| `REDIS_URL` | Redis connection string | -| `LANYARD_USER_ID` | Your Discord user ID | -| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL | -| `BADGE_API_URL` | Uses the [badge api](https://git.creations.works/creations/badgeAPI) only required if you want to use badges | +| 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)) | +| `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 | -| `stars` | Enables stars background effect (`true`) | -| `colors` | Enables avatar-based color theme (uses `node-vibrant`) | -| `badges` | Enables or disables fetching of badges per user | +| 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`) | +| `colors` | Enables avatar-based color theming (uses `node-vibrant`) | --- -### 3. Start the App +### 3. Start the Instance ```bash bun run start ``` -Then open `http://localhost:8080` in your browser. - --- ## Docker Support @@ -80,17 +92,7 @@ Then open `http://localhost:8080` in your browser. docker compose up -d --build ``` -Make sure your `.env` file is correctly configured before starting. - ---- - -## Tech Stack - -- Bun – Runtime -- EJS – Templating -- CSS – Styling -- node-vibrant – Avatar color extraction -- Biome.js – Linting and formatting +Make sure the `.env` file is configured correctly before starting the container. --- From 6bbf474b93cfe1cc8db0c271d77e8379de365b23 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 22 Apr 2025 20:21:24 -0400 Subject: [PATCH 31/85] add env var, add docker files, idk how i forgot there in the readme --- .env.example | 2 +- Dockerfile | 38 ++++++++++++++++++++++++++++++++++++++ compose.yml | 29 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 compose.yml diff --git a/.env.example b/.env.example index 689378b..ce66f4f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ HOST=0.0.0.0 PORT=8080 -REDIS_URL=redis://username:password@localhost:6379 +REDIS_URL=redis://dragonfly:6379 REDIS_TTL=3600 # seconds # this is only the default value if non is give in /id 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/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 From 92f22800992300365a84ab661b3af56fa6c946c0 Mon Sep 17 00:00:00 2001 From: creations Date: Tue, 22 Apr 2025 20:21:44 -0400 Subject: [PATCH 32/85] i never actually added the var --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index 60606d4..fad7c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,7 @@ main().catch((error: Error) => { logger.error(["Error initializing the server:", error]); process.exit(1); }); + +if (process.env.IN_PTERODACTYL === "true") { + console.log("Server Started"); +} From bd680ab6079e59ebf0bad0f436cbe7b08cf76998 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 23 Apr 2025 11:58:55 -0400 Subject: [PATCH 33/85] move to npm logger, fix favicon, clan badges, user avatar --- package.json | 1 + public/css/index.css | 6 +- src/helpers/char.ts | 6 -- src/helpers/logger.ts | 205 ------------------------------------------ src/index.ts | 2 +- src/routes/[id].ts | 8 +- src/server.ts | 6 +- src/views/index.ejs | 4 +- src/websocket.ts | 2 +- 9 files changed, 18 insertions(+), 222 deletions(-) delete mode 100644 src/helpers/char.ts delete mode 100644 src/helpers/logger.ts diff --git a/package.json b/package.json index 4627118..1a81a77 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typescript": "^5.8.3" }, "dependencies": { + "@creations.works/logger": "^1.0.3", "ejs": "^3.1.10", "isomorphic-dompurify": "^2.23.0", "marked": "^15.0.7", diff --git a/public/css/index.css b/public/css/index.css index 623c429..b10bdc8 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -222,8 +222,8 @@ body { } .clan-badge { - width: 50px; - height: 20px; + width: fit-content; + height: fit-content; border-radius: 8px; background-color: var(--card-bg); @@ -232,7 +232,7 @@ body { align-items: center; justify-content: center; gap: 0.3rem; - padding: .4rem 0.5rem; + padding: .3rem .5rem; text-align: center; align-items: center; 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/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 fad7c96..11b1e84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { serverHandler } from "@/server"; diff --git a/src/routes/[id].ts b/src/routes/[id].ts index a4b8769..d859502 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -41,11 +41,12 @@ async function handler(request: ExtendedRequest): Promise { status = presence.discord_status; } + 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/${Math.floor(Math.random() * 5)}.png`; + 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); } @@ -64,6 +65,7 @@ async function handler(request: ExtendedRequest): Promise { instance: instance, readme: readme, badgeApi: presence.kv.badges !== "false" ? badgeApi : null, + avatar: avatar, colors: colors?.colors ?? {}, extraOptions: { snow: presence.kv.snow === "true", diff --git a/src/server.ts b/src/server.ts index 8c7c0ba..39004ce 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import { type BunFile, FileSystemRouter, @@ -44,7 +44,9 @@ class ServerHandler { ]; logger.info(`Server running at ${accessUrls[0]}`); - logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, true); + logger.info(`Access via: ${accessUrls[1]} or ${accessUrls[2]}`, { + breakLine: true, + }); this.logRoutes(); } diff --git a/src/views/index.ejs b/src/views/index.ejs index aced41f..a313b4b 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -30,6 +30,8 @@ <% } %> + + @@ -41,7 +43,7 @@
    - Avatar + Avatar <% if (user.avatar_decoration_data) { %> Decoration <% } %> diff --git a/src/websocket.ts b/src/websocket.ts index 99686e8..7b65476 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,4 +1,4 @@ -import { logger } from "@helpers/logger"; +import { logger } from "@creations.works/logger"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { From 3b6c68c25d33c2ecd69d17de5b44253d8f51ecbd Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 25 Apr 2025 21:20:08 -0400 Subject: [PATCH 34/85] add css kv var, move away from ssr ( multiple queries ), remove colors kv var, add option to disable logging per route --- README.md | 2 +- biome.json | 5 +- package.json | 3 +- public/css/index.css | 33 ++++++ public/css/root.css | 29 +++++ public/js/index.js | 149 +++++++++++++++++++----- public/js/snow.js | 156 +++++++++++++------------- public/js/stars.js | 112 +++++++++--------- src/helpers/colors.ts | 49 -------- src/helpers/lanyard.ts | 97 ---------------- src/routes/[id].ts | 69 ++---------- src/routes/api/colors.ts | 38 ------- src/routes/api/css.ts | 90 +++++++++++++++ src/routes/api/readme.ts | 105 +++++++++++++++++ src/server.ts | 44 ++++---- src/views/index.ejs | 218 +++--------------------------------- src/views/partial/style.ejs | 31 ----- types/routes.d.ts | 8 +- 18 files changed, 571 insertions(+), 667 deletions(-) create mode 100644 public/css/root.css delete mode 100644 src/helpers/colors.ts delete mode 100644 src/helpers/lanyard.ts delete mode 100644 src/routes/api/colors.ts create mode 100644 src/routes/api/css.ts create mode 100644 src/routes/api/readme.ts delete mode 100644 src/views/partial/style.ejs diff --git a/README.md b/README.md index 396d1b3..a7a1269 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ These can be defined in Lanyard's KV store to customize the page: | `stars` | Enables starfield background (`true` / `false`) | | `badges` | Enables badge fetching (`true` / `false`) | | `readme` | URL to a README displayed on the profile (`.md` or `.html`) | -| `colors` | Enables avatar-based color theming (uses `node-vibrant`) | +| `css` | URL to a css to change styles on the page, no import or require allowed | --- 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/package.json b/package.json index 1a81a77..96f4946 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@creations.works/logger": "^1.0.3", "ejs": "^3.1.10", "isomorphic-dompurify": "^2.23.0", - "marked": "^15.0.7", - "node-vibrant": "^4.0.3" + "marked": "^15.0.7" } } diff --git a/public/css/index.css b/public/css/index.css index b10bdc8..145949e 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -40,6 +40,39 @@ } } +#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; 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 0181d5c..0dcd03e 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -353,13 +353,45 @@ if (badgeURL && badgeURL !== "null" && userId) { }); } -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 updatePresence(data) { + const cssLink = data.kv?.css; - const userInfo = document.querySelector(".user-info"); - const customStatus = userInfo?.querySelector(".custom-status"); + if (cssLink) { + 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); + } catch (err) { + console.error("Failed to load CSS", err); + } + } + + const avatarWrapper = document.querySelector(".avatar-wrapper"); + + const avatarImg = document.querySelector(".avatar-wrapper .avatar"); + const usernameEl = document.querySelector(".username"); + + 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 (usernameEl) { + const username = + data.discord_user.global_name || data.discord_user.username; + usernameEl.textContent = username; + document.title = username; + } const platform = { mobile: data.active_on_discord_mobile, @@ -374,37 +406,51 @@ function updatePresence(data) { status = data.discord_status; } - if (statusIndicator) { - statusIndicator.className = `status-indicator ${status}`; - } + let updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); + const updatedMobileIcon = avatarWrapper.querySelector( + ".platform-icon.mobile-only", + ); - if (platform.mobile && !mobileIcon) { + if (platform.mobile && !updatedMobileIcon) { avatarWrapper.innerHTML += ` `; - } else if (!platform.mobile && mobileIcon) { - mobileIcon.remove(); + } else if (!platform.mobile && updatedMobileIcon) { + updatedMobileIcon.remove(); avatarWrapper.innerHTML += `
    `; } - 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}` : ""} - `; + updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); + + if (updatedStatusIndicator) { + updatedStatusIndicator.className = `status-indicator ${status}`; } + const readmeSection = document.querySelector(".readme"); + + if (readmeSection && data.kv?.readme) { + const url = data.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"); + } catch (err) { + console.error("Failed to load README", err); + readmeSection.classList.add("hidden"); + } + } else if (readmeSection) { + readmeSection.classList.add("hidden"); + } + + const custom = data.activities?.find((a) => a.type === 4); + updateCustomStatus(custom); + const filtered = data.activities ?.filter((a) => a.type !== 4) ?.sort((a, b) => { @@ -428,6 +474,47 @@ function updatePresence(data) { updateElapsedAndProgress(); getAllNoAsset(); } + + if (data.kv?.snow === "true") loadEffectScript("snow"); + if (data.kv?.rain === "true") loadEffectScript("rain"); + if (data.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() { @@ -454,3 +541,13 @@ async function getAllNoAsset() { } } } + +function loadEffectScript(effect) { + const existing = document.querySelector(`script[data-effect="${effect}"]`); + if (existing) return; + + const script = document.createElement("script"); + script.src = `/public/js/${effect}.js`; + script.dataset.effect = effect; + document.head.appendChild(script); +} diff --git a/public/js/snow.js b/public/js/snow.js index 05048a8..e1027fc 100644 --- a/public/js/snow.js +++ b/public/js/snow.js @@ -1,84 +1,82 @@ -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"; + snowflake.style.width = `${Math.random() * 3 + 2}px`; + snowflake.style.height = snowflake.style.width; + snowflake.style.background = "white"; + snowflake.style.borderRadius = "50%"; + snowflake.style.opacity = Math.random(); + snowflake.style.left = `${Math.random() * window.innerWidth}px`; + snowflake.style.top = `-${snowflake.style.height}`; + snowflake.speed = Math.random() * 3 + 2; + snowflake.directionX = (Math.random() - 0.5) * 0.5; + snowflake.directionY = Math.random() * 0.5 + 0.5; + + snowflakes.push(snowflake); + snowContainer.appendChild(snowflake); +}; + +setInterval(createSnowflake, 80); + +function updateSnowflakes() { + snowflakes.forEach((snowflake, index) => { + const rect = snowflake.getBoundingClientRect(); + + const dx = rect.left + rect.width / 2 - mouse.x; + const dy = rect.top + rect.height / 2 - mouse.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 30) { + snowflake.directionX += (dx / distance) * 0.02; + snowflake.directionY += (dy / distance) * 0.02; + } else { + snowflake.directionX += (Math.random() - 0.5) * 0.01; + snowflake.directionY += (Math.random() - 0.5) * 0.01; + } + + snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`; + snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`; + + if (rect.top + rect.height >= window.innerHeight) { + snowContainer.removeChild(snowflake); + snowflakes.splice(index, 1); + } + + if ( + rect.left > window.innerWidth || + rect.top > window.innerHeight || + rect.left < 0 + ) { + snowflake.style.left = `${Math.random() * window.innerWidth}px`; + snowflake.style.top = `-${snowflake.style.height}`; + } + }); + + requestAnimationFrame(updateSnowflakes); +} + +updateSnowflakes(); diff --git a/public/js/stars.js b/public/js/stars.js index 093b21a..d35d995 100644 --- a/public/js/stars.js +++ b/public/js/stars.js @@ -1,67 +1,65 @@ -document.addEventListener("DOMContentLoaded", () => { - 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); +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"); - const size = Math.random() * 2 + 1; - star.style.position = "absolute"; - star.style.width = `${size}px`; - star.style.height = `${size}px`; - star.style.background = "white"; - star.style.borderRadius = "50%"; - star.style.opacity = Math.random(); - star.style.top = `${Math.random() * 100}vh`; - star.style.left = `${Math.random() * 100}vw`; - star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate ease-in-out`; - container.appendChild(star); - } +for (let i = 0; i < 60; i++) { + const star = document.createElement("div"); + const size = Math.random() * 2 + 1; + star.style.position = "absolute"; + star.style.width = `${size}px`; + star.style.height = `${size}px`; + star.style.background = "white"; + star.style.borderRadius = "50%"; + star.style.opacity = Math.random(); + star.style.top = `${Math.random() * 100}vh`; + star.style.left = `${Math.random() * 100}vw`; + star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate ease-in-out`; + container.appendChild(star); +} - function createShootingStar() { - const star = document.createElement("div"); - star.classList.add("shooting-star"); +function createShootingStar() { + const star = document.createElement("div"); + star.classList.add("shooting-star"); - let x = Math.random() * window.innerWidth * 0.8; - let y = Math.random() * window.innerHeight * 0.3; - const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; - const speed = 10; - const totalFrames = 60; + let x = Math.random() * window.innerWidth * 0.8; + let y = Math.random() * window.innerHeight * 0.3; + const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8; + const speed = 10; + const totalFrames = 60; - const deg = angle * (180 / Math.PI); + const deg = angle * (180 / Math.PI); + star.style.left = `${x}px`; + star.style.top = `${y}px`; + star.style.transform = `rotate(${deg}deg)`; + + container.appendChild(star); + + let frame = 0; + function animate() { + x += Math.cos(angle) * speed; + y += Math.sin(angle) * speed; star.style.left = `${x}px`; star.style.top = `${y}px`; - star.style.transform = `rotate(${deg}deg)`; + star.style.opacity = `${1 - frame / totalFrames}`; - container.appendChild(star); - - let frame = 0; - function animate() { - x += Math.cos(angle) * speed; - y += Math.sin(angle) * speed; - star.style.left = `${x}px`; - star.style.top = `${y}px`; - star.style.opacity = `${1 - frame / totalFrames}`; - - frame++; - if (frame < totalFrames) { - requestAnimationFrame(animate); - } else { - container.removeChild(star); - } + frame++; + if (frame < totalFrames) { + requestAnimationFrame(animate); + } else { + container.removeChild(star); } - - animate(); } - setInterval(() => { - if (Math.random() < 0.3) createShootingStar(); - }, 1000); -}); + animate(); +} + +setInterval(() => { + if (Math.random() < 0.3) createShootingStar(); +}, 1000); 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/lanyard.ts b/src/helpers/lanyard.ts deleted file mode 100644 index c13ba06..0000000 --- a/src/helpers/lanyard.ts +++ /dev/null @@ -1,97 +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; - const validExtension = /\.(md|markdown|txt|html?)$/i; - - if ( - !userReadMe || - !userReadMe.startsWith("http") || - !validExtension.test(userReadMe) - ) { - return null; - } - - try { - const res: Response = await fetch(userReadMe, { - headers: { - Accept: "text/markdown", - }, - }); - - if (!res.ok) 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; - - let html: string; - if ( - userReadMe.toLowerCase().endsWith(".html") || - userReadMe.toLowerCase().endsWith(".htm") - ) { - html = text; - } else { - html = await marked.parse(text); - } - - const safe: string | null = DOMPurify.sanitize(html); - return safe; - } catch { - return null; - } -} diff --git a/src/routes/[id].ts b/src/routes/[id].ts index d859502..201d0a4 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -1,7 +1,5 @@ -import { getImageColors } from "@/helpers/colors"; import { badgeApi, lanyardConfig } from "@config/environment"; import { renderEjsTemplate } from "@helpers/ejs"; -import { getLanyardData, handleReadMe } from "@helpers/lanyard"; const routeDef: RouteDef = { method: "GET", @@ -11,67 +9,18 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { id } = request.params; - const data: LanyardResponse = await getLanyardData( - id || lanyardConfig.userId, - ); - - 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; - } - - 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/${Math.floor(Math.random() * 5)}.png`; - - let colors: ImageColorResult | null = null; - if (presence.kv.colors === "true") { - colors = await getImageColors(avatar, true); - } + const instance = lanyardConfig.instance + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); 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, - }, + title: "Discord Profile", + username: "", + user: { id: id || lanyardConfig.userId }, instance: instance, - readme: readme, - badgeApi: presence.kv.badges !== "false" ? badgeApi : null, - avatar: avatar, - colors: colors?.colors ?? {}, - extraOptions: { - snow: presence.kv.snow === "true", - rain: presence.kv.rain === "true", - stars: presence.kv.stars === "true", - }, + badgeApi: badgeApi, + avatar: `https://cdn.discordapp.com/embed/avatars/${Math.floor(Math.random() * 5)}.png`, + extraOptions: {}, }; return await renderEjsTemplate("index", ejsTemplateData); 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..20e359e --- /dev/null +++ b/src/routes/api/css.ts @@ -0,0 +1,90 @@ +import { fetch } from "bun"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "*/*", + log: false, +}; + +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 res = await fetch(url, { + headers: { + Accept: "text/css", + }, + }); + + if (!res.ok) { + return Response.json( + { + success: false, + error: { + code: "FETCH_FAILED", + message: "Failed to fetch CSS file", + }, + }, + { status: 400 }, + ); + } + + if (res.headers.has("content-length")) { + const size = Number.parseInt(res.headers.get("content-length") || "0", 10); + if (size > 1024 * 50) { + return Response.json( + { + success: false, + error: { + code: "FILE_TOO_LARGE", + message: "CSS file exceeds 50KB limit", + }, + }, + { status: 400 }, + ); + } + } + + const text = await res.text(); + if (!text || text.length < 5) { + return Response.json( + { + success: false, + error: { + code: "INVALID_CONTENT", + message: "CSS content is too small or invalid", + }, + }, + { status: 400 }, + ); + } + + const sanitized = text + .replace(/[\s\S]*?<\/script>/gi, "") + .replace(/@import\s+url\(['"]?(.*?)['"]?\);?/gi, ""); + + 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..a9d05a3 --- /dev/null +++ b/src/routes/api/readme.ts @@ -0,0 +1,105 @@ +import { fetch } from "bun"; +import DOMPurify from "isomorphic-dompurify"; +import { marked } from "marked"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "*/*", + log: false, +}; + +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 res = await fetch(url, { + headers: { + Accept: "text/markdown", + }, + }); + + if (!res.ok) { + return Response.json( + { + success: false, + error: { + code: "FETCH_FAILED", + message: "Failed to fetch the file", + }, + }, + { status: 400 }, + ); + } + + if (res.headers.has("content-length")) { + const size = Number.parseInt(res.headers.get("content-length") || "0", 10); + if (size > 1024 * 100) { + return Response.json( + { + success: false, + error: { + code: "FILE_TOO_LARGE", + message: "File size exceeds 100KB limit", + }, + }, + { status: 400 }, + ); + } + } + + const text = await res.text(); + if (!text || text.length < 10) { + return Response.json( + { + success: false, + error: { + code: "INVALID_CONTENT", + message: "File is too small or invalid", + }, + }, + { status: 400 }, + ); + } + + let html: string; + + if ( + url.toLowerCase().endsWith(".html") || + url.toLowerCase().endsWith(".htm") + ) { + html = text; + } else { + html = await marked.parse(text); + } + + const safe = DOMPurify.sanitize(html) || ""; + + 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/server.ts b/src/server.ts index 39004ce..2bc28ae 100644 --- a/src/server.ts +++ b/src/server.ts @@ -109,6 +109,8 @@ class ServerHandler { let requestBody: unknown = {}; let response: Response; + let logRequest = true; + if (match) { const { filePath, params, query } = match; @@ -119,6 +121,8 @@ class ServerHandler { ? contentType.split(";")[0].trim() : null; + logRequest = routeModule.routeDef.log !== false; + if ( routeModule.routeDef.needsBody === "json" && actualContentType === "application/json" @@ -227,28 +231,30 @@ class ServerHandler { ); } - const headers = request.headers; - let ip = server.requestIP(request)?.address; + if (logRequest) { + const headers = request.headers; + let ip = server.requestIP(request)?.address; - 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"; + 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"; + } + + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); } - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - return response; } } diff --git a/src/views/index.ejs b/src/views/index.ejs index a313b4b..8fff4b8 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -5,230 +5,48 @@ - <% - const displayName = username.endsWith('s') ? `${username}'` : `${username}'s`; - const profileUrl = `https://discord.com/users/${user.id}`; - %> - - - - - - <%= title %> + Discord Presence - - - <% if (extraOptions.snow) { %> - - <% } %> - <% if(extraOptions.rain) { %> - - <% } %> - <% if (extraOptions.stars) { %> - - <% } %> + - - + - <%- include("partial/style.ejs") %> - +
    +
    +
    + +
    - Avatar - <% if (user.avatar_decoration_data) { %> - Decoration - <% } %> - <% if (platform.mobile) { %> - - - - <% } else { %> -
    - <% } %> + +
    - <% if(badgeApi) { %> -
    - <% } %> - <% - 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' }) %> - -
        - <% } %> -
        -
        -
        "> - " <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>> - " src="<%= smallArt %>" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>> -
        -
        -
        - <% - 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/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/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; -}; From 7d0c65ff8c588848cb06e57f74c992916724e42d Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 25 Apr 2025 23:19:36 -0400 Subject: [PATCH 35/85] move discord badges before other badges --- public/js/index.js | 51 +++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index 0dcd03e..d753b0f 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -295,6 +295,7 @@ if (badgeURL && badgeURL !== "null" && userId) { seperated = false, cache = true, targetId = "badges", + serviceOrder = [], } = options; const params = new URLSearchParams(); @@ -317,24 +318,35 @@ if (badgeURL && badgeURL !== "null" && userId) { return; } - const badges = Array.isArray(json.badges) - ? json.badges - : Object.values(json.badges).flat(); + target.innerHTML = ""; - if (badges.length === 0) { - target.innerHTML = ""; - target.classList.add("hidden"); - return; + 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.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); + } } - target.innerHTML = ""; - for (const badge of badges) { - const img = document.createElement("img"); - img.src = badge.badge; - img.alt = badge.tooltip; - img.title = badge.tooltip; - img.className = "badge"; - target.appendChild(img); + 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"); @@ -347,9 +359,10 @@ if (badgeURL && badgeURL !== "null" && userId) { loadBadges(userId, { services: [], - seperated: false, + seperated: true, cache: true, targetId: "badges", + serviceOrder: ["discord", "equicord", "reviewdb", "vencord"], }); } @@ -428,6 +441,9 @@ async function updatePresence(data) { updatedStatusIndicator.className = `status-indicator ${status}`; } + const custom = data.activities?.find((a) => a.type === 4); + updateCustomStatus(custom); + const readmeSection = document.querySelector(".readme"); if (readmeSection && data.kv?.readme) { @@ -448,9 +464,6 @@ async function updatePresence(data) { readmeSection.classList.add("hidden"); } - const custom = data.activities?.find((a) => a.type === 4); - updateCustomStatus(custom); - const filtered = data.activities ?.filter((a) => a.type !== 4) ?.sort((a, b) => { From 397dc422c5a1578b36674472d62d089ea4157cbb Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 26 Apr 2025 06:25:39 -0400 Subject: [PATCH 36/85] fix issue with status indactor --- public/js/index.js | 48 +++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index d753b0f..682f298 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -419,25 +419,43 @@ async function updatePresence(data) { status = data.discord_status; } - let updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); - const updatedMobileIcon = avatarWrapper.querySelector( - ".platform-icon.mobile-only", - ); + for (const el of avatarWrapper.querySelectorAll(".platform-icon")) { + const platformType = ["mobile-only", "desktop-only", "web-only"].find(type => el.classList.contains(type)); - if (platform.mobile && !updatedMobileIcon) { - avatarWrapper.innerHTML += ` - - - - `; - } else if (!platform.mobile && updatedMobileIcon) { - updatedMobileIcon.remove(); - avatarWrapper.innerHTML += `
      `; + 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}`); + } } - updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); + 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 = ` + + `; + avatarWrapper.appendChild(mobileIcon); + } - if (updatedStatusIndicator) { + 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}`; } From 1e5b754ac9892a965c0da83766ae34d7ab2d2f7f Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 26 Apr 2025 06:28:19 -0400 Subject: [PATCH 37/85] Fix lint --- public/js/index.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index 682f298..c492bf3 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -420,7 +420,9 @@ async function updatePresence(data) { } for (const el of avatarWrapper.querySelectorAll(".platform-icon")) { - const platformType = ["mobile-only", "desktop-only", "web-only"].find(type => el.classList.contains(type)); + const platformType = ["mobile-only", "desktop-only", "web-only"].find( + (type) => el.classList.contains(type), + ); if (!platformType) continue; @@ -436,8 +438,14 @@ async function updatePresence(data) { } } - if (platform.mobile && !avatarWrapper.querySelector(".platform-icon.mobile-only")) { - const mobileIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + 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"); @@ -450,7 +458,8 @@ async function updatePresence(data) { avatarWrapper.appendChild(mobileIcon); } - const updatedStatusIndicator = avatarWrapper.querySelector(".status-indicator"); + const updatedStatusIndicator = + avatarWrapper.querySelector(".status-indicator"); if (!updatedStatusIndicator) { const statusDiv = document.createElement("div"); statusDiv.className = `status-indicator ${status}`; From 634d9192396742b0da2db4dc73b299dd1a9aaa57 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 26 Apr 2025 06:38:03 -0400 Subject: [PATCH 38/85] fix readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7a1269..3ae1241 100644 --- a/README.md +++ b/README.md @@ -98,4 +98,4 @@ Make sure the `.env` file is configured correctly before starting the container. ## License -[MIT](/LICENSE) +[MIT](LICENSE) From 1020e3ee2663e990e604bc32909049e0606bf9b8 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 26 Apr 2025 07:29:56 -0400 Subject: [PATCH 39/85] fix issue with badge loading, profile indef loading if no user, remove unused files, organize the js --- public/js/index.js | 294 +++++++++++++++++++++++--------------------- src/views/error.ejs | 17 --- src/views/index.ejs | 2 +- types/lanyard.d.ts | 72 ----------- types/logger.d.ts | 9 -- 5 files changed, 158 insertions(+), 236 deletions(-) delete mode 100644 src/views/error.ejs delete mode 100644 types/lanyard.d.ts delete mode 100644 types/logger.d.ts diff --git a/public/js/index.js b/public/js/index.js index c492bf3..b9235e8 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1,5 +1,12 @@ +const head = document.querySelector("head"); +const userId = head?.dataset.userId; const activityProgressMap = new Map(); +let instanceUri = head?.dataset.instanceUri; +let badgeURL = head?.dataset.badgeUrl; +let socket; +let badgesLoaded = false; + function formatTime(ms) { const totalSecs = Math.floor(ms / 1000); const hours = Math.floor(totalSecs / 3600); @@ -78,57 +85,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; -let badgeURL = head?.dataset.badgeUrl; - -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) { @@ -280,95 +244,76 @@ function buildActivityHTML(activity) { `; } -if (badgeURL && badgeURL !== "null" && userId) { - if (!badgeURL.startsWith("http")) { - badgeURL = `https://${badgeURL}`; - } +async function loadBadges(userId, options = {}) { + const { + services = [], + seperated = false, + cache = true, + targetId = "badges", + serviceOrder = [], + } = options; - if (!badgeURL.endsWith("/")) { - badgeURL += "/"; - } + const params = new URLSearchParams(); + if (services.length) params.set("services", services.join(",")); + if (seperated) params.set("seperated", "true"); + if (!cache) params.set("cache", "false"); - async function loadBadges(userId, options = {}) { - const { - services = [], - seperated = false, - cache = true, - targetId = "badges", - serviceOrder = [], - } = options; + const url = `${badgeURL}${userId}?${params.toString()}`; + const target = document.getElementById(targetId); + if (!target) return; - const params = new URLSearchParams(); - if (services.length) params.set("services", services.join(",")); - if (seperated) params.set("seperated", "true"); - if (!cache) params.set("cache", "false"); + target.classList.add("hidden"); - const url = `${badgeURL}${userId}?${params.toString()}`; - const target = document.getElementById(targetId); - if (!target) return; + try { + const res = await fetch(url); + const json = await res.json(); - target.classList.add("hidden"); - - try { - const res = await fetch(url); - const json = await res.json(); - - if (!res.ok || !json.badges) { - target.textContent = "Failed to load badges."; - return; - } - - 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.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"); + if (!res.ok || !json.badges) { + target.textContent = "Failed to load badges."; + return; } - } - loadBadges(userId, { - services: [], - seperated: true, - cache: true, - targetId: "badges", - serviceOrder: ["discord", "equicord", "reviewdb", "vencord"], - }); + 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.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 updatePresence(data) { const cssLink = data.kv?.css; - if (cssLink) { try { const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`); @@ -384,10 +329,37 @@ async function updatePresence(data) { } const avatarWrapper = document.querySelector(".avatar-wrapper"); - - const avatarImg = document.querySelector(".avatar-wrapper .avatar"); + const avatarImg = avatarWrapper?.querySelector(".avatar"); 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 (!badgesLoaded) { + loadBadges(userId, { + services: [], + seperated: true, + cache: true, + targetId: "badges", + serviceOrder: ["discord", "equicord", "reviewdb", "vencord"], + }); + badgesLoaded = true; + } + if (avatarImg && data.discord_user?.avatar) { const newAvatarUrl = `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}`; avatarImg.src = newAvatarUrl; @@ -582,12 +554,60 @@ async function getAllNoAsset() { } } -function loadEffectScript(effect) { - const existing = document.querySelector(`script[data-effect="${effect}"]`); - if (existing) return; +if (instanceUri) { + if (!instanceUri.startsWith("http")) { + instanceUri = `https://${instanceUri}`; + } - const script = document.createElement("script"); - script.src = `/public/js/${effect}.js`; - script.dataset.effect = effect; - document.head.appendChild(script); + 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.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); + }); +} + +updateElapsedAndProgress(); +setInterval(updateElapsedAndProgress, 1000); 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 index 8fff4b8..cb59ff0 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -27,7 +27,7 @@
      -
      +
      - + - -
        +
        + +
          +
          - + + - + From 60a52df5fa1e09adc5fd6a0692190735f4b14947 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 3 May 2025 07:35:48 -0400 Subject: [PATCH 63/85] idk how i forgot the favicon --- public/assets/favicon.ico | Bin 15406 -> 172168 bytes public/assets/favicon.png | Bin 0 -> 9262 bytes src/views/index.html | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 public/assets/favicon.png diff --git a/public/assets/favicon.ico b/public/assets/favicon.ico index 69ec50db0ae55e9b54fd9fbf88c34bb1c420fb63..72ecc3473a96c1a8fd67ef162e898b44026feb9a 100644 GIT binary patch literal 172168 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 literal 15406 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 SzegVcKwer!s#?P2%l`n^>h@*; literal 0 HcmV?d00001 diff --git a/src/views/index.html b/src/views/index.html index a43a2f6..3ad05c4 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -8,7 +8,7 @@ Discord Presence - + From 784330b5a61ee50b4bc37642c312377588807bce Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 4 May 2025 11:28:40 -0400 Subject: [PATCH 64/85] fix rain,snow,stars, activity title --- public/css/index.css | 8 ++++++++ public/js/index.js | 2 +- public/js/rain.js | 39 +++++++++++++++++++++++++-------------- public/js/snow.js | 43 ++++++++++++++++++++++++++++--------------- public/js/stars.js | 28 +++++++++++++--------------- src/views/index.html | 2 +- 6 files changed, 76 insertions(+), 46 deletions(-) diff --git a/public/css/index.css b/public/css/index.css index eb8ee18..63fe0c2 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -350,6 +350,14 @@ ul { margin: 0; } +.activities-section .activity-block-header { + margin: 1rem 0 .5rem; + font-size: 2rem; + font-weight: 600; + + text-align: center; +} + .activities { display: flex; flex-direction: column; diff --git a/public/js/index.js b/public/js/index.js index aa086f7..c405af6 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -501,7 +501,7 @@ async 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) { 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 e1027fc..4d3e755 100644 --- a/public/js/snow.js +++ b/public/js/snow.js @@ -25,17 +25,22 @@ const createSnowflake = () => { 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; + 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.style.left = `${Math.random() * window.innerWidth}px`; - snowflake.style.top = `-${snowflake.style.height}`; + + 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); }; @@ -44,10 +49,12 @@ setInterval(createSnowflake, 80); function updateSnowflakes() { snowflakes.forEach((snowflake, index) => { - const rect = snowflake.getBoundingClientRect(); + const size = Number.parseFloat(snowflake.style.width); + const centerX = snowflake.x + size / 2; + const centerY = snowflake.y + size / 2; - const dx = rect.left + rect.width / 2 - mouse.x; - const dy = rect.top + rect.height / 2 - mouse.y; + const dx = centerX - mouse.x; + const dy = centerY - mouse.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < 30) { @@ -58,21 +65,27 @@ function updateSnowflakes() { 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`; + snowflake.x += snowflake.directionX * snowflake.speed; + snowflake.y += snowflake.directionY * snowflake.speed; - if (rect.top + rect.height >= window.innerHeight) { + 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 ( - rect.left > window.innerWidth || - rect.top > window.innerHeight || - rect.left < 0 + snowflake.x > window.innerWidth || + snowflake.y > window.innerHeight || + snowflake.x < 0 ) { - snowflake.style.left = `${Math.random() * window.innerWidth}px`; - snowflake.style.top = `-${snowflake.style.height}`; + snowflake.x = Math.random() * window.innerWidth; + snowflake.y = -size; + snowflake.style.left = `${snowflake.x}px`; + snowflake.style.top = `${snowflake.y}px`; } }); diff --git a/public/js/stars.js b/public/js/stars.js index d35d995..6fff4eb 100644 --- a/public/js/stars.js +++ b/public/js/stars.js @@ -11,48 +11,46 @@ 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.position = "absolute"; star.style.width = `${size}px`; star.style.height = `${size}px`; - star.style.background = "white"; - star.style.borderRadius = "50%"; star.style.opacity = Math.random(); star.style.top = `${Math.random() * 100}vh`; star.style.left = `${Math.random() * 100}vw`; - star.style.animation = `twinkle ${Math.random() * 3 + 2}s infinite alternate ease-in-out`; + star.style.animationDuration = `${Math.random() * 3 + 2}s`; container.appendChild(star); } function createShootingStar() { const star = document.createElement("div"); - star.classList.add("shooting-star"); + star.className = "shooting-star"; - let x = Math.random() * window.innerWidth * 0.8; - let y = Math.random() * window.innerHeight * 0.3; + 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 = `${x}px`; - star.style.top = `${y}px`; + star.style.left = `${star.x}px`; + star.style.top = `${star.y}px`; star.style.transform = `rotate(${deg}deg)`; container.appendChild(star); - let frame = 0; function animate() { - x += Math.cos(angle) * speed; - y += Math.sin(angle) * speed; - star.style.left = `${x}px`; - star.style.top = `${y}px`; + 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 { + } else if (star.parentNode === container) { container.removeChild(star); } } diff --git a/src/views/index.html b/src/views/index.html index 3ad05c4..b5e313b 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -53,7 +53,7 @@
          - +
            From ba67ba55e3dbebc435ae4802761112585ea8884f Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 7 May 2025 15:18:37 -0400 Subject: [PATCH 65/85] fix issue with indef loading for unkown users --- public/js/index.js | 59 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index c405af6..3a83592 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -335,9 +335,10 @@ async function loadBadges(userId, options = {}) { async function populateReadme(data) { const readmeSection = document.querySelector(".readme"); + const kv = data.kv || {}; - if (readmeSection && data.kv?.readme) { - const url = data.kv.readme; + 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"); @@ -355,8 +356,33 @@ async function populateReadme(data) { } } -async function updatePresence(data) { - const cssLink = data.kv?.css; +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 || {}; + + const cssLink = kv.css; if (cssLink) { try { const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`); @@ -371,7 +397,7 @@ async function updatePresence(data) { } } - if (!badgesLoaded && data && data.kv.badges !== "false") { + if (!badgesLoaded && data?.kv && data.kv.badges !== "false") { loadBadges(userId, { services: [], seperated: true, @@ -515,9 +541,9 @@ async function updatePresence(data) { getAllNoAsset(); } - if (data.kv?.snow === "true") loadEffectScript("snow"); - if (data.kv?.rain === "true") loadEffectScript("rain"); - if (data.kv?.stars === "true") loadEffectScript("stars"); + 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) { @@ -643,6 +669,19 @@ if (userId && instanceUri) { 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 })); @@ -659,8 +698,8 @@ if (userId && instanceUri) { } if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") { - updatePresence(payload.d); - requestAnimationFrame(() => updateElapsedAndProgress()); + updatePresence(payload); + requestAnimationFrame(updateElapsedAndProgress); } }); From 5ad5d7181f5b139314171a7a0a99b315941da952 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 7 May 2025 15:21:39 -0400 Subject: [PATCH 66/85] add option for users to opt out --- README.md | 1 + public/js/index.js | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 42d9f64..eddbc04 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ These can be defined in Lanyard's KV store to customize the page: | `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`) | --- diff --git a/public/js/index.js b/public/js/index.js index 3a83592..a32bbd8 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -382,6 +382,19 @@ async function updatePresence(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) { try { From 9aa58ae23fc26c48f9af9ba932870cdf208bbc8f Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 10 May 2025 12:46:58 -0400 Subject: [PATCH 67/85] add review db, fix issues with spamming css url and readme whenever status updated --- .env.example | 4 ++ config/environment.ts | 5 ++ public/css/index.css | 152 ++++++++++++++++++++++++++++++++++++++++++ public/js/index.js | 105 ++++++++++++++++++++++++++++- src/routes/[id].ts | 11 ++- src/views/index.html | 6 ++ 6 files changed, 281 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 92211b4..626a366 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ 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 + + # https://www.steamgriddb.com/api/v2, if you want games to have images STEAMGRIDDB_API_KEY=steamgrid_api_key diff --git a/config/environment.ts b/config/environment.ts index 4d48b88..dec203b 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -14,6 +14,11 @@ export const lanyardConfig: LanyardConfig = { instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", }; +export const reviewDb = { + enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1", + url: "https://manti.vendicated.dev/api/reviewdb", +}; + export const badgeApi: string | null = process.env.BADGE_API_URL || null; export const steamGridDbKey: string | undefined = process.env.STEAMGRIDDB_API_KEY; diff --git a/public/css/index.css b/public/css/index.css index 63fe0c2..1593bf9 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -814,3 +814,155 @@ 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-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; +} + +@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; + } +} diff --git a/public/js/index.js b/public/js/index.js index a32bbd8..d80afa7 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -2,10 +2,18 @@ const head = document.querySelector("head"); const userId = head?.dataset.userId; const activityProgressMap = new Map(); +const reviewURL = head?.dataset.reviewDb; let instanceUri = head?.dataset.instanceUri; let badgeURL = head?.dataset.badgeUrl; let socket; + let badgesLoaded = false; +let readmeLoaded = false; +let cssLoaded = false; + +let currentReviewPage = 1; +let hasMoreReviews = true; +let isLoadingReviews = false; function formatTime(ms) { const totalSecs = Math.floor(ms / 1000); @@ -119,6 +127,93 @@ function resolveActivityImage(img, applicationId) { return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; } +async function populateReviews(userId, page = 1) { + if (!reviewURL || !userId || isLoadingReviews || !hasMoreReviews) return; + const reviewSection = document.querySelector(".reviews"); + const reviewList = reviewSection?.querySelector(".reviews-list"); + if (!reviewList) return; + + isLoadingReviews = true; + + try { + const url = `${reviewURL}/users/${userId}/reviews?page=${page}`; + const res = await fetch(url); + const data = await res.json(); + + if (!data.success || !Array.isArray(data.reviews)) { + if (page === 1) reviewSection.classList.add("hidden"); + isLoadingReviews = false; + return; + } + + const reviewsHTML = data.reviews + .slice(1) + .map((review) => { + const sender = review.sender; + const username = sender.username; + const avatar = sender.profilePhoto; + const comment = review.comment; + const timestamp = review.timestamp + ? new Date(review.timestamp * 1000).toLocaleString() + : "N/A"; + + const badges = (sender.badges || []) + .map( + (b) => + `${b.name}`, + ) + .join(""); + + return ` +
          • + ${username}'s avatar +
            +
            + ${username} + ${timestamp} +
            +
            ${badges}
            +
            ${comment}
            +
            +
          • + `; + }) + .join(""); + + if (page === 1) reviewList.innerHTML = reviewsHTML; + else reviewList.insertAdjacentHTML("beforeend", reviewsHTML); + + reviewSection.classList.remove("hidden"); + + hasMoreReviews = data.hasNextPage; + currentReviewPage = page; + isLoadingReviews = false; + } catch (err) { + console.error("Failed to fetch reviews", err); + isLoadingReviews = false; + } +} + +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) { + populateReviews(userId, currentReviewPage + 1); + } + }, + { + rootMargin: "200px", + threshold: 0, + }, + ); + + observer.observe(sentinel); +} + function buildActivityHTML(activity) { const start = activity.timestamps?.start; const end = activity.timestamps?.end; @@ -334,6 +429,8 @@ async function loadBadges(userId, options = {}) { } async function populateReadme(data) { + if (readmeLoaded) return; + const readmeSection = document.querySelector(".readme"); const kv = data.kv || {}; @@ -347,6 +444,7 @@ async function populateReadme(data) { readmeSection.innerHTML = `
            ${text}
            `; readmeSection.classList.remove("hidden"); + readmeLoaded = true; } catch (err) { console.error("Failed to load README", err); readmeSection.classList.add("hidden"); @@ -396,7 +494,7 @@ async function updatePresence(initialData) { } const cssLink = kv.css; - if (cssLink) { + if (cssLink && !cssLoaded) { try { const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`); if (!res.ok) throw new Error("Failed to fetch CSS"); @@ -405,6 +503,7 @@ async function updatePresence(initialData) { const style = document.createElement("style"); style.textContent = cssText; document.head.appendChild(style); + cssLoaded = true; } catch (err) { console.error("Failed to load CSS", err); } @@ -462,6 +561,10 @@ async function updatePresence(initialData) { } updateClanBadge(data); + if (kv.reviews !== "false") { + populateReviews(userId, 1); + setupReviewScrollObserver(userId); + } const platform = { mobile: data.active_on_discord_mobile, diff --git a/src/routes/[id].ts b/src/routes/[id].ts index 55fe2e8..28921ec 100644 --- a/src/routes/[id].ts +++ b/src/routes/[id].ts @@ -1,5 +1,10 @@ import { resolve } from "node:path"; -import { badgeApi, lanyardConfig, plausibleScript } from "@config/environment"; +import { + badgeApi, + lanyardConfig, + plausibleScript, + reviewDb, +} from "@config/environment"; import { file } from "bun"; const routeDef: RouteDef = { @@ -24,6 +29,10 @@ async function handler(request: ExtendedRequest): Promise { head.setAttribute("data-instance-uri", instance); head.setAttribute("data-badge-url", badgeApi || ""); + if (reviewDb.enabled) { + head.setAttribute("data-review-db", reviewDb.url); + } + if (plausibleScript) { head.append(plausibleScript, { html: true }); } diff --git a/src/views/index.html b/src/views/index.html index b5e313b..79ec446 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -62,6 +62,12 @@

            + + From 87b09af73e63d343f4404599a283bad644e4d49b Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 10 May 2025 12:51:11 -0400 Subject: [PATCH 68/85] forgot readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index eddbc04..48d4a39 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ cp .env.example .env | `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 | | `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons | #### Optional Lanyard KV Variables (per-user customization) @@ -77,6 +78,7 @@ These can be defined in Lanyard's KV store to customize the page: | `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`) | --- From 453a79a4e42c118e489b4d43a3fa40ea7f808dc5 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 10 May 2025 12:54:57 -0400 Subject: [PATCH 69/85] to 24h --- public/js/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/public/js/index.js b/public/js/index.js index d80afa7..51ef047 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -154,7 +154,14 @@ async function populateReviews(userId, page = 1) { const avatar = sender.profilePhoto; const comment = review.comment; const timestamp = review.timestamp - ? new Date(review.timestamp * 1000).toLocaleString() + ? 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 || []) From dbdb59f48bb447cf4b9bfdca271cbd58b26baaa8 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 10 May 2025 13:05:10 -0400 Subject: [PATCH 70/85] fix the scroll and "page" logic --- public/js/index.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index 51ef047..1193627 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -11,7 +11,8 @@ let badgesLoaded = false; let readmeLoaded = false; let cssLoaded = false; -let currentReviewPage = 1; +const reviewsPerPage = 50; +let currentReviewOffset = 0; let hasMoreReviews = true; let isLoadingReviews = false; @@ -127,7 +128,7 @@ function resolveActivityImage(img, applicationId) { return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`; } -async function populateReviews(userId, page = 1) { +async function populateReviews(userId) { if (!reviewURL || !userId || isLoadingReviews || !hasMoreReviews) return; const reviewSection = document.querySelector(".reviews"); const reviewList = reviewSection?.querySelector(".reviews-list"); @@ -136,7 +137,7 @@ async function populateReviews(userId, page = 1) { isLoadingReviews = true; try { - const url = `${reviewURL}/users/${userId}/reviews?page=${page}`; + const url = `${reviewURL}/users/${userId}/reviews?flags=2&offset=${currentReviewOffset}`; const res = await fetch(url); const data = await res.json(); @@ -147,7 +148,6 @@ async function populateReviews(userId, page = 1) { } const reviewsHTML = data.reviews - .slice(1) .map((review) => { const sender = review.sender; const username = sender.username; @@ -187,7 +187,7 @@ async function populateReviews(userId, page = 1) { }) .join(""); - if (page === 1) reviewList.innerHTML = reviewsHTML; + if (currentReviewOffset === 0) reviewList.innerHTML = reviewsHTML; else reviewList.insertAdjacentHTML("beforeend", reviewsHTML); reviewSection.classList.remove("hidden"); @@ -208,8 +208,9 @@ function setupReviewScrollObserver(userId) { const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMoreReviews) { - populateReviews(userId, currentReviewPage + 1); + if (entries[0].isIntersecting && hasMoreReviews && !isLoadingReviews) { + currentReviewOffset += reviewsPerPage; + populateReviews(userId); } }, { @@ -569,7 +570,7 @@ async function updatePresence(initialData) { updateClanBadge(data); if (kv.reviews !== "false") { - populateReviews(userId, 1); + populateReviews(userId); setupReviewScrollObserver(userId); } From aa24c979ee0c21772c7ad3ce7a30c9bac636ac54 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 12 May 2025 18:13:29 -0400 Subject: [PATCH 71/85] add robots txt route, verifiy required env vars func, fix issue with reviewdb badges and emojis --- .gitignore | 1 + public/css/index.css | 29 +++++++++++ public/js/index.js | 36 ++++++++------ src/index.ts | 3 ++ src/server.ts | 112 ++++++++++++++++++++++++++++--------------- 5 files changed, 129 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index 3f0b6ea..5030290 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ bun.lock .env logs/ .vscode/ +robots.txt diff --git a/public/css/index.css b/public/css/index.css index 1593bf9..b8df88e 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -884,6 +884,13 @@ ul { 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); @@ -907,6 +914,23 @@ ul { 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%; @@ -965,4 +989,9 @@ ul { .review-badges { justify-content: center; } + + .emoji { + width: 16px; + height: 16px; + } } diff --git a/public/js/index.js b/public/js/index.js index 1193627..4c0bfc4 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -142,7 +142,7 @@ async function populateReviews(userId) { const data = await res.json(); if (!data.success || !Array.isArray(data.reviews)) { - if (page === 1) reviewSection.classList.add("hidden"); + if (currentReviewOffset === 0) reviewSection.classList.add("hidden"); isLoadingReviews = false; return; } @@ -152,7 +152,14 @@ async function populateReviews(userId) { const sender = review.sender; const username = sender.username; const avatar = sender.profilePhoto; - const comment = review.comment; + 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, @@ -172,18 +179,20 @@ async function populateReviews(userId) { .join(""); return ` -
          • - ${username}'s avatar -
            -
            - ${username} - ${timestamp} +
          • + ${username}'s avatar +
            +
            +
            + ${username} + ${badges} +
            + ${timestamp} +
            +
            ${comment}
            -
            ${badges}
            -
            ${comment}
            -
          • - - `; + + `; }) .join(""); @@ -193,7 +202,6 @@ async function populateReviews(userId) { reviewSection.classList.remove("hidden"); hasMoreReviews = data.hasNextPage; - currentReviewPage = page; isLoadingReviews = false; } catch (err) { console.error("Failed to fetch reviews", err); diff --git a/src/index.ts b/src/index.ts index 2836a3f..89b3704 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import { serverHandler } from "@/server"; +import { verifyRequiredVariables } from "@config/environment"; import { logger } from "@creations.works/logger"; async function main(): Promise { + verifyRequiredVariables(); + serverHandler.initialize(); } diff --git a/src/server.ts b/src/server.ts index 2bc28ae..c936afa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path"; -import { environment } from "@config/environment"; +import { environment, robotstxtPath } from "@config/environment"; import { logger } from "@creations.works/logger"; import { type BunFile, @@ -65,10 +65,15 @@ class ServerHandler { } } - 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 { @@ -81,16 +86,37 @@ 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 { + logger.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 }); + 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 { + logger.custom( + `[${request.method}]`, + `(${response.status})`, + [ + request.url, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ], + "90", + ); } private async handleRequest( @@ -100,16 +126,52 @@ 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; + if (pathname === "/robots.txt" && robotstxtPath) { + try { + const file: BunFile = Bun.file(robotstxtPath); + + if (await file.exists()) { + const fileContent: ArrayBuffer = await file.arrayBuffer(); + const contentType: string = file.type || "text/plain"; + + response = new Response(fileContent, { + headers: { "Content-Type": contentType }, + }); + } else { + logger.warn(`File not found: ${robotstxtPath}`); + response = new Response("Not Found", { status: 404 }); + } + } catch (error) { + logger.error([ + `Error serving robots.txt: ${robotstxtPath}`, + error as Error, + ]); + response = new Response("Internal Server Error", { status: 500 }); + } + + 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; - - let logRequest = true; if (match) { const { filePath, params, query } = match; @@ -121,8 +183,6 @@ class ServerHandler { ? contentType.split(";")[0].trim() : null; - logRequest = routeModule.routeDef.log !== false; - if ( routeModule.routeDef.needsBody === "json" && actualContentType === "application/json" @@ -231,30 +291,6 @@ class ServerHandler { ); } - if (logRequest) { - const headers = request.headers; - let ip = server.requestIP(request)?.address; - - 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"; - } - - logger.custom( - `[${request.method}]`, - `(${response.status})`, - [ - request.url, - `${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`, - ip || "unknown", - ], - "90", - ); - } - return response; } } From a25aff0e24e230588b4eadbc4457cbb76a0e1afa Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 12 May 2025 18:49:53 -0400 Subject: [PATCH 72/85] forgot to stage --- config/environment.ts | 60 ++++++++++++++++++++++++++++++++++++------- src/index.ts | 1 - 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/config/environment.ts b/config/environment.ts index dec203b..a4da0eb 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -1,27 +1,69 @@ -export const environment: Environment = { +import { resolve } from "node:path"; +import { logger } from "@creations.works/logger"; + +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 redisTtl: number = process.env.REDIS_TTL +const redisTtl: number = process.env.REDIS_TTL ? Number.parseInt(process.env.REDIS_TTL, 10) : 60 * 60 * 1; // 1 hour -export const lanyardConfig: LanyardConfig = { +const lanyardConfig: LanyardConfig = { userId: process.env.LANYARD_USER_ID || "", - instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", + instance: process.env.LANYARD_INSTANCE || "", }; -export const reviewDb = { +const reviewDb = { enabled: process.env.REVIEW_DB === "true" || process.env.REVIEW_DB === "1", url: "https://manti.vendicated.dev/api/reviewdb", }; -export const badgeApi: string | null = process.env.BADGE_API_URL || null; -export const steamGridDbKey: string | undefined = - process.env.STEAMGRIDDB_API_KEY; +const badgeApi: string | null = process.env.BADGE_API_URL || null; +const steamGridDbKey: string | undefined = process.env.STEAMGRIDDB_API_KEY; -export const plausibleScript: string | null = +const plausibleScript: string | null = process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null; + +const robotstxtPath: string | null = process.env.ROBOTS_FILE + ? resolve(process.env.ROBOTS_FILE) + : 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() === "") { + logger.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export { + environment, + lanyardConfig, + redisTtl, + reviewDb, + badgeApi, + steamGridDbKey, + plausibleScript, + robotstxtPath, + verifyRequiredVariables, +}; diff --git a/src/index.ts b/src/index.ts index 89b3704..692b84e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ import { logger } from "@creations.works/logger"; async function main(): Promise { verifyRequiredVariables(); - serverHandler.initialize(); } From 11ab56b9b3fd552f754e91c87128e62352568a76 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 12 May 2025 18:52:43 -0400 Subject: [PATCH 73/85] forgot readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 48d4a39..3b0cf84 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ cp .env.example .env | `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) | | `REVIEW_DB` | Enables showing reviews from reviewdb on user pages | | `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons | +| `ROBOTS_FILE` | If there it uses the file in /robots.txt route, requires a valid path | #### Optional Lanyard KV Variables (per-user customization) From fca71334e9605b900144ed12fc0c436178c314ba Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 14 May 2025 10:35:46 -0400 Subject: [PATCH 74/85] fix listening --- public/js/index.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/public/js/index.js b/public/js/index.js index 4c0bfc4..3ff4eb8 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -258,18 +258,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 From bf52c02122c53a2c7dd2a441b37562d66e0f2847 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 14 May 2025 10:36:19 -0400 Subject: [PATCH 75/85] remove uneeded --- public/css/error.css | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 public/css/error.css 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; -} From 9d6b9e40a7d245825e692cd9018477e270f53842 Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 16 May 2025 18:53:15 -0400 Subject: [PATCH 76/85] fix missing av decoration, missed when moving to js --- public/css/index.css | 8 ++++---- public/js/index.js | 14 ++++++++++++++ src/views/index.html | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/public/css/index.css b/public/css/index.css index b8df88e..157f884 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -177,10 +177,10 @@ main { .decoration { position: absolute; - top: -18px; - left: -18px; - width: 164px; - height: 164px; + top: -13px; + left: -16px; + width: 160px; + height: 160px; pointer-events: none; } diff --git a/public/js/index.js b/public/js/index.js index 3ff4eb8..bb2a3e1 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -535,6 +535,7 @@ async function updatePresence(initialData) { 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) { @@ -566,6 +567,19 @@ async function updatePresence(initialData) { } } + 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; diff --git a/src/views/index.html b/src/views/index.html index 79ec446..d92aaa8 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -40,6 +40,7 @@
            +