add redis, game icons and fix readme
Some checks failed
Code quality checks / biome (push) Failing after 10s
Some checks failed
Code quality checks / biome (push) Failing after 10s
This commit is contained in:
parent
245215265a
commit
6d46ef48d0
7 changed files with 153 additions and 21 deletions
|
@ -2,9 +2,15 @@
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8080
|
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
|
# this is only the default value if non is give in /id
|
||||||
LANYARD_USER_ID=id-here
|
LANYARD_USER_ID=id-here
|
||||||
LANYARD_INSTANCE=https://lanyard.rest
|
LANYARD_INSTANCE=https://lanyard.rest
|
||||||
|
|
||||||
# Required if you want to enable badges
|
# Required if you want to enable badges
|
||||||
BADGE_API_URL=http://localhost:8081
|
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
|
||||||
|
|
15
README.md
15
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.
|
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.
|
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.
|
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
|
## Getting Started
|
||||||
|
@ -34,9 +42,10 @@ cp .env.example .env
|
||||||
|--------------------|--------------------------------------------------|
|
|--------------------|--------------------------------------------------|
|
||||||
| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) |
|
| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) |
|
||||||
| `PORT` | Port to run the server on (default: `8080`) |
|
| `PORT` | Port to run the server on (default: `8080`) |
|
||||||
|
| `REDIS_URL` | Redis connection string |
|
||||||
| `LANYARD_USER_ID` | Your Discord user ID |
|
| `LANYARD_USER_ID` | Your Discord user ID |
|
||||||
| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL |
|
| `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)
|
#### Optional Lanyard KV Vars (per-user customization)
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,15 @@ export const environment: Environment = {
|
||||||
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
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 = {
|
export const lanyardConfig: LanyardConfig = {
|
||||||
userId: process.env.LANYARD_USER_ID || "",
|
userId: process.env.LANYARD_USER_ID || "",
|
||||||
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
|
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const badgeApi: string | null = process.env.BADGE_API_URL || null;
|
export const badgeApi: string | null = process.env.BADGE_API_URL || null;
|
||||||
|
export const steamGridDbKey: string | undefined =
|
||||||
|
process.env.STEAMGRIDDB_API_KEY;
|
||||||
|
|
|
@ -316,6 +316,10 @@ ul {
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-asset {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.activity-image-small {
|
.activity-image-small {
|
||||||
width: 25px;
|
width: 25px;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
|
|
|
@ -240,12 +240,15 @@ function buildActivityHTML(activity) {
|
||||||
const secondaryLine = isMusic ? activity.state : activity.details;
|
const secondaryLine = isMusic ? activity.state : activity.details;
|
||||||
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
|
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
|
||||||
|
|
||||||
const activityArt = art
|
const activityArt = `<div class="activity-image-wrapper ${art ?? "no-asset"}">
|
||||||
? `<div class="activity-image-wrapper">
|
<img
|
||||||
<img class="activity-image" src="${art}" alt="Art" ${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}>
|
class="activity-image${!art ? " no-asset" : ""}"
|
||||||
${smallArt ? `<img class="activity-image-small" src="${smallArt}" alt="Small Art" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>` : ""}
|
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>`
|
</div>`
|
||||||
: "";
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<li class="activity">
|
<li class="activity">
|
||||||
|
@ -423,5 +426,31 @@ function updatePresence(data) {
|
||||||
activitiesTitle.classList.add("hidden");
|
activitiesTitle.classList.add("hidden");
|
||||||
}
|
}
|
||||||
updateElapsedAndProgress();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
82
src/routes/api/art[game].ts
Normal file
82
src/routes/api/art[game].ts
Normal 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 };
|
|
@ -32,9 +32,9 @@
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
<%- include("partial/style.ejs") %>
|
<%- include("partial/style.ejs") %>
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="user-card">
|
<div class="user-card">
|
||||||
<div class="avatar-status-wrapper">
|
<div class="avatar-status-wrapper">
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
|
@ -158,14 +158,10 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-wrapper-inner">
|
<div class="activity-wrapper-inner">
|
||||||
<% if (art) { %>
|
<div class="activity-image-wrapper <%= art ?? "no-asset" %>">
|
||||||
<div class="activity-image-wrapper">
|
<img class="activity-image <%= art ? '' : 'no-asset' %>" src="<%= art || '' %>" data-name="<%= activity.name.replace(/"/g, '"') %>" <%= activity.assets?.large_text ? `title="${activity.assets.large_text}"` : '' %>>
|
||||||
<img class="activity-image" src="<%= art %>" alt="Art" <%= 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}"` : '' %>>
|
||||||
<% if (smallArt) { %>
|
|
||||||
<img class="activity-image-small" src="<%= smallArt %>" alt="Small Art" <%= activity.assets?.small_text ? `title="${activity.assets.small_text}"` : '' %>>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
|
||||||
<div class="activity-content">
|
<div class="activity-content">
|
||||||
<div class="inner-content">
|
<div class="inner-content">
|
||||||
<%
|
<%
|
||||||
|
|
Loading…
Add table
Reference in a new issue