diff --git a/.env.example b/.env.example
index bf1f90d..689378b 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index 5e6644c..aa09058 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/config/environment.ts b/config/environment.ts
index 1476062..a7344f9 100644
--- a/config/environment.ts
+++ b/config/environment.ts
@@ -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;
diff --git a/public/css/index.css b/public/css/index.css
index d7e31e6..4728251 100644
--- a/public/css/index.css
+++ b/public/css/index.css
@@ -316,6 +316,10 @@ ul {
height: 80px;
}
+.no-asset {
+ display: none !important;
+}
+
.activity-image-small {
width: 25px;
height: 25px;
diff --git a/public/js/index.js b/public/js/index.js
index 74dd8cb..998bb3d 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -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
- ? `
-

- ${smallArt ? `

` : ""}
+ const activityArt = `
+

+ ${`

`}
`
- : "";
return `
@@ -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);
+ }
}
}
diff --git a/src/routes/api/art[game].ts b/src/routes/api/art[game].ts
new file mode 100644
index 0000000..3a945f6
--- /dev/null
+++ b/src/routes/api/art[game].ts
@@ -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 {
+ const cacheKey = `steamgrid:icon:${gameName.toLowerCase()}`;
+ const cached = await redis.get(cacheKey);
+ if (cached) return cached;
+
+ const search = await fetch(`https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameName)}`, {
+ headers: {
+ Authorization: `Bearer ${steamGridDbKey}`,
+ },
+ });
+
+ if (!search.ok) return null;
+
+ const { data } = await search.json();
+ if (!data?.length) return null;
+
+ const gameId = data[0]?.id;
+ if (!gameId) return null;
+
+ const iconRes = await fetch(`https://www.steamgriddb.com/api/v2/icons/game/${gameId}`, {
+ headers: {
+ Authorization: `Bearer ${steamGridDbKey}`,
+ },
+ });
+
+ if (!iconRes.ok) return null;
+
+ const iconData = await iconRes.json();
+ const icon = iconData?.data?.[0]?.url ?? null;
+
+ if (icon) {
+ await redis.set(cacheKey, icon);
+ await redis.expire(cacheKey, redisTtl);
+ }
+ return icon;
+}
+
+async function handler(request: ExtendedRequest): Promise {
+ if (!steamGridDbKey) {
+ return Response.json(
+ { status: 503, error: "Route disabled due to missing SteamGridDB key" },
+ { status: 503 }
+ );
+ }
+
+ const { game } = request.params;
+
+ if (!game || typeof game !== "string" || game.length < 2) {
+ return Response.json({ status: 400, error: "Missing or invalid game name" }, { status: 400 });
+ }
+
+ const icon = await fetchSteamGridIcon(game);
+
+ if (!icon) {
+ return Response.json({ status: 404, error: "Icon not found" }, { status: 404 });
+ }
+
+ return Response.json(
+ {
+ status: 200,
+ game,
+ icon,
+ },
+ { status: 200 }
+ );
+}
+
+export { handler, routeDef };
diff --git a/src/views/index.ejs b/src/views/index.ejs
index 8194055..d704836 100644
--- a/src/views/index.ejs
+++ b/src/views/index.ejs
@@ -32,9 +32,9 @@
-<%- include("partial/style.ejs") %>
-
+ <%- include("partial/style.ejs") %>
+
@@ -54,10 +54,10 @@
<%= username %>
<% if (user.clan && user.clan.tag) { %>
-
-

-
<%= user.clan.tag %>
-
+
+

+
<%= user.clan.tag %>
+
<% } %>
<% if (activities.length && activities[0].type === 4) {
@@ -158,14 +158,10 @@
<% } %>
- <% if (art) { %>
-
-

>
- <% if (smallArt) { %>
-

>
- <% } %>
+
">
+

" <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>>
+
![]()
" src="<%= smallArt %>" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>>
- <% } %>