add redis, game icons and fix readme
Some checks failed
Code quality checks / biome (push) Failing after 10s

This commit is contained in:
creations 2025-04-20 14:39:15 -04:00
parent 245215265a
commit 6d46ef48d0
Signed by: creations
GPG key ID: 8F553AA4320FC711
7 changed files with 153 additions and 21 deletions

View file

@ -2,9 +2,15 @@
HOST=0.0.0.0
PORT=8080
REDIS_URL=redis://username:password@localhost:6379
REDIS_TTL=3600 # seconds
# this is only the default value if non is give in /id
LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081
# https://www.steamgriddb.com/api/v2, if you want games to have images
STEAMGRIDDB_API_KEY=steamgrid_api_key

View file

@ -2,12 +2,20 @@
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS.
## Prerequisite: Lanyard Backend
## Requirements
This project relies on the following services to function correctly:
### 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.
### 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.
---
## Getting Started
@ -34,9 +42,10 @@ cp .env.example .env
|--------------------|--------------------------------------------------|
| `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
| `BADGE_API_URL` | Uses the [badge api](https://git.creations.works/creations/badgeAPI) only required if you want to use badges |
#### Optional Lanyard KV Vars (per-user customization)

View file

@ -5,9 +5,15 @@ export const environment: Environment = {
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
export const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
};
export const badgeApi: string | null = process.env.BADGE_API_URL || null;
export const steamGridDbKey: string | undefined =
process.env.STEAMGRIDDB_API_KEY;

View file

@ -316,6 +316,10 @@ ul {
height: 80px;
}
.no-asset {
display: none !important;
}
.activity-image-small {
width: 25px;
height: 25px;

View file

@ -240,12 +240,15 @@ function buildActivityHTML(activity) {
const secondaryLine = isMusic ? activity.state : activity.details;
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
const activityArt = art
? `<div class="activity-image-wrapper">
<img class="activity-image" src="${art}" alt="Art" ${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}>
${smallArt ? `<img class="activity-image-small" src="${smallArt}" alt="Small Art" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>` : ""}
const activityArt = `<div class="activity-image-wrapper ${art ?? "no-asset"}">
<img
class="activity-image${!art ? " no-asset" : ""}"
src="${art ?? ""}"
data-name="${activity.name}"
${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>`
: "";
return `
<li class="activity">
@ -423,5 +426,31 @@ function updatePresence(data) {
activitiesTitle.classList.add("hidden");
}
updateElapsedAndProgress();
getAllNoAsset();
}
}
async function getAllNoAsset() {
const noAssetImages = document.querySelectorAll("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;
try {
const res = await fetch(`/api/art/${encodeURIComponent(name)}`);
if (!res.ok) continue;
const { icon } = await res.json();
if (icon) {
img.src = icon;
img.classList.remove("no-asset");
img.parentElement.classList.remove("no-asset");
}
} catch (err) {
console.warn(`Failed to fetch fallback icon for "${name}"`, err);
}
}
}

View file

@ -0,0 +1,82 @@
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: "*/*",
returns: "application/json",
};
async function fetchSteamGridIcon(gameName: string): Promise<string | null> {
const cacheKey = `steamgrid:icon:${gameName.toLowerCase()}`;
const cached = await redis.get(cacheKey);
if (cached) return cached;
const search = await fetch(`https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameName)}`, {
headers: {
Authorization: `Bearer ${steamGridDbKey}`,
},
});
if (!search.ok) return null;
const { data } = await search.json();
if (!data?.length) return null;
const gameId = data[0]?.id;
if (!gameId) return null;
const iconRes = await fetch(`https://www.steamgriddb.com/api/v2/icons/game/${gameId}`, {
headers: {
Authorization: `Bearer ${steamGridDbKey}`,
},
});
if (!iconRes.ok) return null;
const iconData = await iconRes.json();
const icon = iconData?.data?.[0]?.url ?? null;
if (icon) {
await redis.set(cacheKey, icon);
await redis.expire(cacheKey, redisTtl);
}
return icon;
}
async function handler(request: ExtendedRequest): Promise<Response> {
if (!steamGridDbKey) {
return Response.json(
{ status: 503, error: "Route disabled due to missing SteamGridDB key" },
{ status: 503 }
);
}
const { game } = request.params;
if (!game || typeof game !== "string" || game.length < 2) {
return Response.json({ status: 400, error: "Missing or invalid game name" }, { status: 400 });
}
const icon = await fetchSteamGridIcon(game);
if (!icon) {
return Response.json({ status: 404, error: "Icon not found" }, { status: 404 });
}
return Response.json(
{
status: 200,
game,
icon,
},
{ status: 200 }
);
}
export { handler, routeDef };

View file

@ -32,9 +32,9 @@
<meta name="color-scheme" content="dark">
</head>
<%- include("partial/style.ejs") %>
<body>
<%- include("partial/style.ejs") %>
<div class="user-card">
<div class="avatar-status-wrapper">
<div class="avatar-wrapper">
@ -158,14 +158,10 @@
<% } %>
</div>
<div class="activity-wrapper-inner">
<% if (art) { %>
<div class="activity-image-wrapper">
<img class="activity-image" src="<%= art %>" alt="Art" <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>>
<% if (smallArt) { %>
<img class="activity-image-small" src="<%= smallArt %>" alt="Small Art" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>>
<% } %>
<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">
<%