add css kv var, move away from ssr ( multiple queries ), remove colors kv var, add option to disable logging per route
All checks were successful
Code quality checks / biome (push) Successful in 15s

This commit is contained in:
creations 2025-04-25 21:20:08 -04:00
parent bd680ab607
commit 3b6c68c25d
Signed by: creations
GPG key ID: 8F553AA4320FC711
18 changed files with 571 additions and 667 deletions

View file

@ -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 |
---

View file

@ -26,7 +26,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
},
"javascript": {

View file

@ -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"
}
}

View file

@ -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;

29
public/css/root.css Normal file
View file

@ -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;
}

View file

@ -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,36 +406,50 @@ 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 += `
<svg class="platform-icon mobile-only ${status}" viewBox="0 0 1000 1500" fill="#43a25a" aria-label="Mobile" width="17" height="17">
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/>
</svg>
`;
} else if (!platform.mobile && mobileIcon) {
mobileIcon.remove();
} else if (!platform.mobile && updatedMobileIcon) {
updatedMobileIcon.remove();
avatarWrapper.innerHTML += `<div class="status-indicator ${status}"></div>`;
}
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 = `<div class="markdown-body">${text}</div>`;
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);
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 = `<img src="${emojiUrl}" alt="${emoji.name}" class="custom-emoji">`;
} else if (emoji?.name) {
emojiHTML = `${emoji.name} `;
}
customStatus.innerHTML = `
${emojiHTML}
${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
`;
}
updateCustomStatus(custom);
const filtered = data.activities
?.filter((a) => a.type !== 4)
@ -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 = `<img src="${emojiUrl}" alt="${custom.emoji.name}" class="custom-emoji">`;
} else if (custom.emoji?.name) {
emojiHTML = `${custom.emoji.name} `;
}
const html = `
<p class="custom-status">
${emojiHTML}${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
</p>
`;
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);
}

View file

@ -1,4 +1,3 @@
document.addEventListener("DOMContentLoaded", () => {
const snowContainer = document.createElement("div");
snowContainer.style.position = "fixed";
snowContainer.style.top = "0";
@ -81,4 +80,3 @@ document.addEventListener("DOMContentLoaded", () => {
}
updateSnowflakes();
});

View file

@ -1,4 +1,3 @@
document.addEventListener("DOMContentLoaded", () => {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.top = "0";
@ -64,4 +63,3 @@ document.addEventListener("DOMContentLoaded", () => {
setInterval(() => {
if (Math.random() < 0.3) createShootingStar();
}, 1000);
});

View file

@ -1,49 +0,0 @@
import { fetch } from "bun";
import { Vibrant } from "node-vibrant/node";
export async function getImageColors(
url: string,
hex?: boolean,
): Promise<ImageColorResult | null> {
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("")}`;
}

View file

@ -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<LanyardResponse> {
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<string | null> {
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;
}
}

View file

@ -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<Response> {
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<string> | 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);

View file

@ -1,38 +0,0 @@
import { getImageColors } from "@helpers/colors";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "application/json",
};
async function handler(request: ExtendedRequest): Promise<Response> {
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 };

90
src/routes/api/css.ts Normal file
View file

@ -0,0 +1,90 @@
import { fetch } from "bun";
const routeDef: RouteDef = {
method: "GET",
accepts: "*/*",
returns: "*/*",
log: false,
};
async function handler(request: ExtendedRequest): Promise<Response> {
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(/<script[\s\S]*?>[\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 };

105
src/routes/api/readme.ts Normal file
View file

@ -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<Response> {
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 };

View file

@ -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,6 +231,7 @@ class ServerHandler {
);
}
if (logRequest) {
const headers = request.headers;
let ip = server.requestIP(request)?.address;
@ -248,6 +253,7 @@ class ServerHandler {
],
"90",
);
}
return response;
}

View file

@ -5,230 +5,48 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%
const displayName = username.endsWith('s') ? `${username}'` : `${username}'s`;
const profileUrl = `https://discord.com/users/${user.id}`;
%>
<meta property="og:title" content="<%= displayName %> Discord Presence">
<meta property="og:description" content="<%= activities?.[0]?.state || 'See what ' + displayName + ' is doing on Discord.' %>">
<meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>">
<meta name="twitter:card" content="summary_large_image">
<title><%= title %></title>
<title>Discord Presence</title>
<link rel="stylesheet" href="/public/css/index.css">
<script src="/public/js/index.js" defer></script>
<% if (extraOptions.snow) { %>
<script src="/public/js/snow.js" defer></script>
<% } %>
<% if(extraOptions.rain) { %>
<script src="/public/js/rain.js" defer></script>
<% } %>
<% if (extraOptions.stars) { %>
<script src="/public/js/stars.js" defer></script>
<% } %>
<link rel="stylesheet" href="/public/css/root.css">
<meta name="color-scheme" content="dark">
<link rel="icon" href="<%= avatar %>" type="image/png">
<link rel="icon" id="site-icon" type="image/png">
</head>
<body>
<%- include("partial/style.ejs") %>
<a href="https://git.creations.works/creations/profilePage" tareget="_blank" rel="noopener noreferrer">
<div id="loading-overlay">
<div class="loading-spinner"></div>
</div>
<a href="https://git.creations.works/creations/profilePage" target="_blank" rel="noopener noreferrer">
<img class="open-source-logo" src="/public/assets/forgejo_logo.svg" alt="Forgejo Logo" style="opacity: 0.5;">
</a>
<div class="user-card">
<div class="avatar-status-wrapper">
<div class="avatar-wrapper">
<img class="avatar" src="<%= avatar %>" alt="Avatar">
<% if (user.avatar_decoration_data) { %>
<img class="decoration" src="https://cdn.discordapp.com/avatar-decoration-presets/<%= user.avatar_decoration_data.asset %>" alt="Decoration">
<% } %>
<% if (platform.mobile) { %>
<svg class="platform-icon mobile-only <%= status %>" viewBox="0 0 1000 1500" aria-label="Mobile" width="17" height="17">
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z" />
</svg>
<% } else { %>
<div class="status-indicator <%= status %>"></div>
<% } %>
<img class="avatar hidden" src="">
<div class="status-indicator offline"></div>
</div>
<div class="user-info">
<div class="user-info-inner">
<h1><%= username %></h1>
<% if (user.clan && user.clan.tag) { %>
<div class="clan-badge">
<img src="https://cdn.discordapp.com/clan-badges/<%= user.clan.identity_guild_id %>/<%= user.clan.badge %>" alt="Clan Badge" class="clan-badge">
<span class="clan-name"><%= user.clan.tag %></span>
<h1 class="username"></h1>
</div>
<% } %>
</div>
<% if (activities.length && activities[0].type === 4) {
const emoji = activities[0].emoji;
const isCustom = emoji?.id;
const emojiUrl = isCustom
? `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`
: null;
%>
<p class="custom-status">
<% if (isCustom && emojiUrl) { %>
<img src="<%= emojiUrl %>" alt="<%= emoji.name %>" class="custom-emoji">
<% } else if (emoji?.name) { %>
<%= emoji.name %>
<% } %>
<% if (activities[0].state) { %>
<span class="custom-status-text"><%= activities[0].state %></span>
<% } %>
</p>
<% } %>
</div>
</div>
</div>
<% if(badgeApi) { %>
<div id="badges" class="badges"></div>
<% } %>
<%
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;
});
%>
<div id="badges" class="badges hidden"></div>
<h2 class="activity-header <%= filtered.length === 0 ? 'hidden' : '' %>">Activities</h2>
<ul class="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;
<h2 class="activity-header hidden">Activities</h2>
<ul class="activities"></ul>
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";
%>
<li class="activity">
<div class="activity-wrapper">
<div class="activity-type-wrapper">
<span class="activity-type-label" data-type="<%= activity.type %>"><%= activityType %></span>
<% if (start && progress === null) { %>
<div class="activity-timestamp" data-start="<%= start %>">
<% const started = new Date(start); %>
<span>
Since: <%= started.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) %> <span class="elapsed"></span>
</span>
</div>
<% } %>
</div>
<div class="activity-wrapper-inner">
<div class="activity-image-wrapper <%= art ?? "no-asset" %>">
<img class="activity-image <%= art ? '' : 'no-asset' %>" src="<%= art || '' %>" data-name="<%= activity.name.replace(/"/g, '&quot;') %>" <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>>
<img class="activity-image-small <%= smallArt ?? "no-asset" %>" src="<%= smallArt %>" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>>
</div>
<div class="activity-content">
<div class="inner-content">
<%
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;
%>
<div class="activity-top">
<div class="activity-header <%= progress !== null ? 'no-timestamp' : '' %>">
<span class="activity-name"><%= primaryLine %></span>
</div>
<% if (secondaryLine) { %>
<div class="activity-detail"><%= secondaryLine %></div>
<% } %>
<% if (tertiaryLine) { %>
<div class="activity-detail"><%= tertiaryLine %></div>
<% } %>
</div>
<div class="activity-bottom">
<% if (activity.buttons && activity.buttons.length > 0) { %>
<div class="activity-buttons">
<% 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) { %>
<a href="<%= buttonUrl %>" class="activity-button" target="_blank" rel="noopener noreferrer"><%= buttonLabel %></a>
<% } %>
<% }) %>
</div>
<% } %>
</div>
</div>
</div>
</div>
<% if (progress !== null) { %>
<div class="progress-bar" data-start="<%= start %>" data-end="<%= end %>">
<div class="progress-fill" <%= progress !== null ? `style="width: ${progress}%"` : '' %>></div>
</div>
<% if (start && end) { %>
<div class="progress-time-labels" data-start="<%= start %>" data-end="<%= end %>">
<span class="progress-current"></span>
<span class="progress-total"><%= Math.floor((end - start) / 60000) %>:<%= String(Math.floor(((end - start) % 60000) / 1000)).padStart(2, "0") %></span>
</div>
<% } %>
<% } %>
</div>
</li>
<% }); %>
</ul>
<% if (readme) { %>
<section class="readme">
<div class="markdown-body"><%- readme %></div>
<section class="readme hidden">
<div class="markdown-body"></div>
</section>
<% } %>
<script src="/public/js/index.js"></script>
</body>
</html>

View file

@ -1,31 +0,0 @@
<style>
:root {
--background: <%= colors.DarkVibrant || '#0e0e10' %>;
--readme-bg: <%= colors.DarkMuted || '#1a1a1d' %>;
--card-bg: <%= colors.DarkMuted || '#1e1f22' %>;
--card-hover-bg: <%= colors.Muted || '#2a2a2d' %>;
--border-color: <%= colors.Muted || '#2e2e30' %>;
--text-color: #ffffff;
--text-subtle: #bbb;
--text-secondary: #b5bac1;
--text-muted: #888;
--link-color: <%= colors.Vibrant || '#00b0f4' %>;
--button-bg: <%= colors.Vibrant || '#5865f2' %>;
--button-hover-bg: <%= colors.LightVibrant || '#4752c4' %>;
--button-disabled-bg: #2d2e31;
--progress-bg: <%= colors.DarkVibrant || '#f23f43' %>;
--progress-fill: <%= colors.Vibrant || '#5865f2' %>;
--status-online: #23a55a;
--status-idle: #f0b232;
--status-dnd: #e03e3e;
--status-offline: #747f8d;
--status-streaming: #b700ff;
--blockquote-color: #aaa;
--code-bg: <%= colors.Muted || '#2e2e30' %>;
}
</style>

8
types/routes.d.ts vendored
View file

@ -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> | Response;
routeDef: RouteDef;
};
type Palette = Awaited<ReturnType<typeof Vibrant.prototype.getPalette>>;
type Swatch = Awaited<ReturnType<typeof Vibrant.prototype.getSwatches>>;
type ImageColorResult = {
img: string;
colors: Palette | Record<string, string>;
};