From 5239afe04b322b1e713acefbeb5e56bfe5b116a0 Mon Sep 17 00:00:00 2001
From: Juniper Hovey
Date: Wed, 18 Mar 2026 20:48:11 -0700
Subject: [PATCH 1/3] Add mod badges for stats
---
package-lock.json | 21 +-
package.json | 2 +
src/lib/components/ModDetails.svelte | 98 ++++++
src/lib/server/mod-badge-svg.ts | 391 ++++++++++++++++++++++
src/lib/styles/link.scss | 3 +
src/lib/styles/mod-details.scss | 6 +
src/routes/mods/[id]/+page.svelte | 86 +----
src/routes/mods/[id]/badge.svg/+server.ts | 58 ++++
8 files changed, 591 insertions(+), 74 deletions(-)
create mode 100644 src/lib/components/ModDetails.svelte
create mode 100644 src/lib/server/mod-badge-svg.ts
create mode 100644 src/lib/styles/link.scss
create mode 100644 src/lib/styles/mod-details.scss
create mode 100644 src/routes/mods/[id]/badge.svg/+server.ts
diff --git a/package-lock.json b/package-lock.json
index acce894..33d6b41 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "geode-website",
"version": "0.0.1",
"dependencies": {
+ "@fontsource/poppins": "^5.2.7",
+ "@iconify-json/mdi": "^1.2.3",
"dayjs": "^1.11.11",
"redis": "^5.9.0",
"svelte-exmarkdown": "^5.0.2"
@@ -486,6 +488,24 @@
"node": ">=18"
}
},
+ "node_modules/@fontsource/poppins": {
+ "version": "5.2.7",
+ "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz",
+ "integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@iconify-json/mdi": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@iconify-json/mdi/-/mdi-1.2.3.tgz",
+ "integrity": "sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@iconify/types": "*"
+ }
+ },
"node_modules/@iconify/svelte": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@iconify/svelte/-/svelte-5.1.0.tgz",
@@ -506,7 +526,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
- "dev": true,
"license": "MIT"
},
"node_modules/@img/colour": {
diff --git a/package.json b/package.json
index 9e71926..31fdb45 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,8 @@
},
"type": "module",
"dependencies": {
+ "@fontsource/poppins": "^5.2.7",
+ "@iconify-json/mdi": "^1.2.3",
"dayjs": "^1.11.11",
"redis": "^5.9.0",
"svelte-exmarkdown": "^5.0.2"
diff --git a/src/lib/components/ModDetails.svelte b/src/lib/components/ModDetails.svelte
new file mode 100644
index 0000000..ea9b36e
--- /dev/null
+++ b/src/lib/components/ModDetails.svelte
@@ -0,0 +1,98 @@
+
+
+
+
+ {#if modVersion}
+
+ {modVersion}
+
+ {/if}
+
+ {#if downloads}
+
+ {formatNumber(downloads)}
+
+ {/if}
+
+ {#if createdAt}
+
+ {serverTimestampToAgoString(createdAt)}
+
+ {/if}
+
+ {#if updatedAt}
+
+ {serverTimestampToAgoString(updatedAt)}
+
+ {/if}
+
+ {#if geodeVersion}
+
+ {geodeVersion}
+
+ {/if}
+
+ {#if gdVersion}
+
+
+
+ {/if}
+
+ {#if tags && tags.length > 0}
+
+
+ {#each tags as tag}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
diff --git a/src/lib/server/mod-badge-svg.ts b/src/lib/server/mod-badge-svg.ts
new file mode 100644
index 0000000..e4bb1d5
--- /dev/null
+++ b/src/lib/server/mod-badge-svg.ts
@@ -0,0 +1,391 @@
+import { readFile } from "node:fs/promises";
+import gdIcon from "$lib/assets/gd-icon.json";
+import geodeIcon from "$lib/assets/geode-icon.json";
+import { formatNumber, icons, serverTimestampToAgoString, type KnownIcon } from "$lib";
+import type { ServerGDVersion } from "$lib/api/models/mod-version.js";
+import mdiIcons from "@iconify-json/mdi/icons.json";
+
+type SvgIconData = {
+ body: string;
+ width?: number;
+ height?: number;
+};
+
+type ModBadgeSvgInput = {
+ modId: string;
+ modVersion?: string;
+ geodeVersion?: string;
+ gdVersion?: ServerGDVersion;
+ createdAt?: string;
+ updatedAt?: string;
+ downloads?: number;
+ stats?: ModBadgeStatKey[];
+};
+
+export const MOD_BADGE_STAT_KEYS = [
+ "version",
+ "geode_version",
+ "gd_version",
+ "created_at",
+ "updated_at",
+ "downloads",
+] as const;
+
+export type ModBadgeStatKey = (typeof MOD_BADGE_STAT_KEYS)[number];
+
+type LabelDescriptor = {
+ text: string;
+ icon?: KnownIcon;
+ fill: string;
+ textColor: string;
+};
+
+type LabelColors = Pick;
+
+type RenderedRow = {
+ svg: string;
+ height: number;
+};
+
+type RenderedLabel = RenderedRow & {
+ width: number;
+};
+
+const CARD_WIDTH = 320;
+const CARD_PADDING = 12;
+const INNER_WIDTH = CARD_WIDTH - CARD_PADDING * 2;
+const INFO_ROW_GAP = 8;
+const BASE_ROW_HEIGHT = 18;
+const ROW_ICON_SIZE = 18;
+const FONT_FAMILY = "Poppins Embedded";
+
+const LABEL_STYLE = {
+ height: 24,
+ gapX: 6,
+ gapY: 6,
+ iconSize: 14,
+ textSize: 12.5,
+ textWeight: 600,
+ paddingX: 8,
+ radius: 6,
+} as const;
+
+const COLORS = {
+ background: "#0c0811",
+ outline: "rgba(205, 152, 189, 0.18)",
+ text: "#f2f2f2",
+ muted: "#cd98bd",
+ grayLabel: "rgba(124, 82, 173, 0.5)",
+ grayLabelText: "#f2f2f2",
+};
+
+const GD_LABEL_COLORS: LabelColors = {
+ fill: COLORS.grayLabel,
+ textColor: COLORS.grayLabelText,
+};
+
+const mdiIconSet = mdiIcons as { icons: Record };
+const customIcons: Partial> = {
+ gd: gdIcon,
+ geode: geodeIcon,
+};
+
+let embeddedFontsCssPromise: Promise | null = null;
+
+const escapeXml = (value: string) =>
+ value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+
+const loadEmbeddedFontsCss = async () => {
+ if (!embeddedFontsCssPromise) {
+ embeddedFontsCssPromise = (async () => {
+ const [regularFont, semiboldFont] = await Promise.all([
+ readFile(
+ new URL(
+ "../../../node_modules/@fontsource/poppins/files/poppins-latin-400-normal.woff2",
+ import.meta.url,
+ ),
+ ),
+ readFile(
+ new URL(
+ "../../../node_modules/@fontsource/poppins/files/poppins-latin-600-normal.woff2",
+ import.meta.url,
+ ),
+ ),
+ ]);
+
+ return [
+ `@font-face { font-family: "${FONT_FAMILY}"; src: url("data:font/woff2;base64,${regularFont.toString("base64")}") format("woff2"); font-style: normal; font-weight: 400; }`,
+ `@font-face { font-family: "${FONT_FAMILY}"; src: url("data:font/woff2;base64,${semiboldFont.toString("base64")}") format("woff2"); font-style: normal; font-weight: 600; }`,
+ `text { font-family: "${FONT_FAMILY}", Arial, sans-serif; }`,
+ ].join("\n");
+ })();
+ }
+
+ return embeddedFontsCssPromise;
+};
+
+const measureTextWidth = (text: string, fontSize: number, weight = 400) => {
+ return Math.ceil(text.length * fontSize * (weight >= 600 ? 0.61 : 0.56));
+};
+
+const measureLabelWidth = (text: string, icon?: KnownIcon) => {
+ return (
+ LABEL_STYLE.paddingX * 2 +
+ (icon ? LABEL_STYLE.iconSize + 4 : 0) +
+ measureTextWidth(text, LABEL_STYLE.textSize, LABEL_STYLE.textWeight)
+ );
+};
+
+const getIconData = (icon: KnownIcon) => {
+ const customIcon = customIcons[icon];
+ if (customIcon) {
+ return customIcon;
+ }
+
+ const iconName = icons[icon];
+ return iconName.startsWith("mdi:") ? mdiIconSet.icons[iconName.slice("mdi:".length)] : undefined;
+};
+
+const renderIcon = (icon: KnownIcon, x: number, y: number, size: number, color: string) => {
+ const data = getIconData(icon);
+ if (!data) {
+ return "";
+ }
+
+ const width = data.width ?? 24;
+ const height = data.height ?? 24;
+ return `${data.body}`;
+};
+
+const renderText = (
+ text: string,
+ x: number,
+ y: number,
+ {
+ size = 16,
+ fill = COLORS.text,
+ weight = 400,
+ }: {
+ size?: number;
+ fill?: string;
+ weight?: number;
+ } = {},
+) => {
+ return `${escapeXml(text)}`;
+};
+
+const renderLabel = (label: LabelDescriptor, x: number, y: number): RenderedLabel => {
+ const width = measureLabelWidth(label.text, label.icon);
+ const parts = [
+ ``,
+ ];
+
+ let contentX = x + LABEL_STYLE.paddingX;
+ if (label.icon) {
+ parts.push(
+ renderIcon(
+ label.icon,
+ contentX,
+ y + (LABEL_STYLE.height - LABEL_STYLE.iconSize) / 2,
+ LABEL_STYLE.iconSize,
+ label.textColor,
+ ),
+ );
+ contentX += LABEL_STYLE.iconSize + 4;
+ }
+
+ parts.push(
+ renderText(label.text, contentX, y + LABEL_STYLE.height / 2 + 0.5, {
+ size: LABEL_STYLE.textSize,
+ fill: label.textColor,
+ weight: LABEL_STYLE.textWeight,
+ }),
+ );
+
+ return {
+ svg: parts.join(""),
+ width,
+ height: LABEL_STYLE.height,
+ };
+};
+
+const layoutLabels = (labels: LabelDescriptor[], startX: number, startY: number, maxWidth: number) => {
+ let x = startX;
+ let y = startY;
+ const parts: string[] = [];
+
+ for (const label of labels) {
+ const width = measureLabelWidth(label.text, label.icon);
+
+ if (x !== startX && x + width > startX + maxWidth) {
+ x = startX;
+ y += LABEL_STYLE.height + LABEL_STYLE.gapY;
+ }
+
+ const rendered = renderLabel(label, x, y);
+ parts.push(rendered.svg);
+ x += rendered.width + LABEL_STYLE.gapX;
+ }
+
+ return {
+ svg: parts.join(""),
+ height: y - startY + LABEL_STYLE.height,
+ };
+};
+
+const renderIconRow = (icon: KnownIcon, text: string, y: number): RenderedRow => {
+ return {
+ svg: `${renderIcon(icon, CARD_PADDING, y + (BASE_ROW_HEIGHT - ROW_ICON_SIZE) / 2, ROW_ICON_SIZE, COLORS.muted)}${renderText(text, CARD_PADDING + ROW_ICON_SIZE + 8, y + BASE_ROW_HEIGHT / 2 + 0.5)}`,
+ height: BASE_ROW_HEIGHT,
+ };
+};
+
+const buildLabel = (text: string, icon: KnownIcon, colors: LabelColors = GD_LABEL_COLORS): LabelDescriptor => {
+ return { text, icon, ...colors };
+};
+
+const addVersionLabel = (labels: LabelDescriptor[], icon: KnownIcon, version: string | null, suffix = "") => {
+ if (!version) {
+ return;
+ }
+
+ labels.push(buildLabel(suffix ? `${version} ${suffix}` : version, icon));
+};
+
+const addMergedVersionLabels = (
+ labels: LabelDescriptor[],
+ icon: KnownIcon,
+ firstVersion: string | null,
+ secondVersion: string | null,
+ firstSuffix: string,
+ secondSuffix: string,
+) => {
+ if (firstVersion && firstVersion === secondVersion) {
+ labels.push(buildLabel(firstVersion, icon));
+ return;
+ }
+
+ addVersionLabel(labels, icon, firstVersion, firstSuffix);
+ addVersionLabel(labels, icon, secondVersion, secondSuffix);
+};
+
+const getSharedGDVersion = (gdVersion: ServerGDVersion) => {
+ const version = gdVersion.ios;
+ return version &&
+ [
+ gdVersion.android32,
+ gdVersion.android64,
+ gdVersion.ios,
+ gdVersion["mac-arm"],
+ gdVersion["mac-intel"],
+ gdVersion.win,
+ ].every((current) => current === version)
+ ? version
+ : null;
+};
+
+const renderGDRow = (gdVersion: ServerGDVersion, y: number): RenderedRow => {
+ const sharedVersion = getSharedGDVersion(gdVersion);
+ if (sharedVersion) {
+ return renderIconRow("gd", sharedVersion, y);
+ }
+
+ const labels: LabelDescriptor[] = [];
+ addVersionLabel(labels, "windows", gdVersion.win);
+ addMergedVersionLabels(labels, "mac", gdVersion["mac-arm"], gdVersion["mac-intel"], "(ARM)", "(x64)");
+ addVersionLabel(labels, "ios", gdVersion.ios);
+ addMergedVersionLabels(labels, "android", gdVersion.android64, gdVersion.android32, "(64-bit)", "(32-bit)");
+
+ const contentX = CARD_PADDING + ROW_ICON_SIZE + 8;
+ const labelsY = y - 3;
+ const laidOut = layoutLabels(labels, contentX, labelsY, INNER_WIDTH - (contentX - CARD_PADDING));
+
+ return {
+ svg: `${renderIcon("gd", CARD_PADDING, labelsY + (LABEL_STYLE.height - ROW_ICON_SIZE) / 2, ROW_ICON_SIZE, COLORS.muted)}${laidOut.svg}`,
+ height: Math.max(BASE_ROW_HEIGHT, laidOut.height),
+ };
+};
+
+export async function renderModBadgeSvg(input: ModBadgeSvgInput) {
+ const parts: string[] = [];
+ let y = CARD_PADDING;
+ const enabledStats = new Set(input.stats ?? MOD_BADGE_STAT_KEYS);
+ const rows: Array<{ stat: ModBadgeStatKey; render: (y: number) => RenderedRow | undefined }> = [
+ {
+ stat: "version",
+ render: (currentY) =>
+ input.modVersion ? renderIconRow("version", escapeXml(input.modVersion), currentY) : undefined,
+ },
+ {
+ stat: "downloads",
+ render: (currentY) =>
+ input.downloads !== undefined
+ ? renderIconRow("download", formatNumber(input.downloads), currentY)
+ : undefined,
+ },
+ {
+ stat: "created_at",
+ render: (currentY) =>
+ input.createdAt
+ ? renderIconRow("time", serverTimestampToAgoString(input.createdAt) ?? input.createdAt, currentY)
+ : undefined,
+ },
+ {
+ stat: "updated_at",
+ render: (currentY) =>
+ input.updatedAt
+ ? renderIconRow("update", serverTimestampToAgoString(input.updatedAt) ?? input.updatedAt, currentY)
+ : undefined,
+ },
+ {
+ stat: "geode_version",
+ render: (currentY) =>
+ input.geodeVersion ? renderIconRow("geode", input.geodeVersion, currentY) : undefined,
+ },
+ {
+ stat: "gd_version",
+ render: (currentY) => (input.gdVersion ? renderGDRow(input.gdVersion, currentY) : undefined),
+ },
+ ];
+
+ for (const { stat, render } of rows) {
+ if (!enabledStats.has(stat)) {
+ continue;
+ }
+
+ const row = render(y);
+ if (!row) {
+ continue;
+ }
+
+ parts.push(row.svg);
+ y += row.height + INFO_ROW_GAP;
+ }
+
+ if (parts.length > 0) {
+ y -= INFO_ROW_GAP;
+ }
+
+ const height = y + CARD_PADDING;
+ const description = [
+ input.modVersion && `Version ${input.modVersion}`,
+ input.downloads !== undefined && `${formatNumber(input.downloads)} downloads`,
+ input.geodeVersion && `Geode ${input.geodeVersion}`,
+ ]
+ .filter(Boolean)
+ .join(", ");
+
+ return `
+`;
+}
diff --git a/src/lib/styles/link.scss b/src/lib/styles/link.scss
new file mode 100644
index 0000000..604d1ef
--- /dev/null
+++ b/src/lib/styles/link.scss
@@ -0,0 +1,3 @@
+.color-link {
+ --link-color: var(--accent-300);
+}
diff --git a/src/lib/styles/mod-details.scss b/src/lib/styles/mod-details.scss
new file mode 100644
index 0000000..0d34e6d
--- /dev/null
+++ b/src/lib/styles/mod-details.scss
@@ -0,0 +1,6 @@
+section {
+ background-color: var(--background-950);
+ padding: 0.75rem;
+ // gap: .5rem;
+ border-radius: 0.5rem;
+}
diff --git a/src/routes/mods/[id]/+page.svelte b/src/routes/mods/[id]/+page.svelte
index dd7d4fe..19b5431 100644
--- a/src/routes/mods/[id]/+page.svelte
+++ b/src/routes/mods/[id]/+page.svelte
@@ -11,13 +11,10 @@
import Tabs from "$lib/components/Tabs.svelte";
import TabPage from "$lib/components/TabPage.svelte";
import Link from "$lib/components/Link.svelte";
- import Icon from "$lib/components/Icon.svelte";
import Gap from "$lib/components/Gap.svelte";
- import { serverTimestampToAgoString, serverTimestampToDateString, formatNumber, iconForTag } from "$lib";
import Waves from "$lib/components/Waves.svelte";
import Label from "$lib/components/Label.svelte";
import InfoBox from "$lib/components/InfoBox.svelte";
- import VersionCards from "$lib/components/VersionCards.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import LoadingOverlay from "$lib/components/LoadingOverlay.svelte";
import VersionCard from "$lib/components/VersionCard.svelte";
@@ -27,6 +24,7 @@
import GeodeMarkdown from "$lib/components/GeodeMarkdown.svelte";
import ModLogo from "$lib/components/ModLogo.svelte";
import ModDevelopersList from "$lib/components/ModDevelopersList.svelte";
+ import ModDetails from "$lib/components/ModDetails.svelte";
interface Props {
data: PageData;
@@ -80,11 +78,6 @@
searching = false;
};
- const getTagDisplay = (tag: string) => {
- const foundTag = data.tags.find((x) => x.name == tag);
- return foundTag ? foundTag.display_name : tag.charAt(0).toUpperCase() + tag.slice(1);
- };
-
let url_params = $derived(page.url.searchParams);
let status = $derived(data.version_params.status ?? "accepted");
let invalid_status = $derived(!verifyStatus(status));
@@ -393,7 +386,7 @@
{#if data.version.early_load || data.version.api}
-
+
{#if data.version.early_load}
{/if}
@@ -442,42 +435,14 @@