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
|
||||
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
|
||||
|
|
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.
|
||||
|
||||
## 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)
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -316,6 +316,10 @@ ul {
|
|||
height: 80px;
|
||||
}
|
||||
|
||||
.no-asset {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.activity-image-small {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
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">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%- include("partial/style.ejs") %>
|
||||
|
||||
<body>
|
||||
<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, '"') %>" <%= 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">
|
||||
<%
|
||||
|
|
Loading…
Add table
Reference in a new issue