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..2a96893
--- /dev/null
+++ b/src/lib/server/mod-badge-svg.ts
@@ -0,0 +1,249 @@
+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 BadgeDescriptor = {
+ label: string;
+ value: string;
+ icon: KnownIcon;
+};
+
+const FONT_FAMILY = "Poppins Embedded";
+const BADGE_HEIGHT = 20;
+const BADGE_RADIUS = 3;
+const BADGE_ICON_SIZE = 12;
+const BADGE_TEXT_SIZE = 11;
+const BADGE_TEXT_WEIGHT = 600;
+const BADGE_LEFT_PADDING = 6;
+const BADGE_RIGHT_PADDING = 6;
+const BADGE_ICON_GAP = 4;
+
+const COLORS = {
+ background: "#0c0811",
+ outline: "rgba(205, 152, 189, 0.18)",
+ text: "#f2f2f2",
+ muted: "#cd98bd",
+ grayLabel: "#5f3d84",
+};
+
+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 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 renderBadgeText = (
+ text: string,
+ x: number,
+ y: number,
+ { anchor = "start" as const, fill = COLORS.text }: { anchor?: "start" | "middle"; fill?: string } = {},
+) => {
+ const safeText = escapeXml(text);
+ return [
+ `${safeText}`,
+ `${safeText}`,
+ ].join("");
+};
+
+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 formatGDVersionValue = (gdVersion: ServerGDVersion) => {
+ const sharedVersion = getSharedGDVersion(gdVersion);
+ if (sharedVersion) {
+ return sharedVersion;
+ }
+
+ const distinctVersions = [
+ gdVersion.win,
+ gdVersion["mac-arm"],
+ gdVersion["mac-intel"],
+ gdVersion.ios,
+ gdVersion.android64,
+ gdVersion.android32,
+ ].filter((version, index, versions): version is string => Boolean(version) && versions.indexOf(version) === index);
+
+ return distinctVersions.join(" / ");
+};
+
+const getBadgeDescriptor = (input: ModBadgeSvgInput, stat: ModBadgeStatKey): BadgeDescriptor | undefined => {
+ switch (stat) {
+ case "version":
+ return input.modVersion ? { label: "Version", value: input.modVersion, icon: "version" } : undefined;
+ case "geode_version":
+ return input.geodeVersion ? { label: "Geode", value: input.geodeVersion, icon: "geode" } : undefined;
+ case "gd_version":
+ return input.gdVersion ? { label: "GD", value: formatGDVersionValue(input.gdVersion), icon: "gd" } : undefined;
+ case "created_at":
+ return input.createdAt
+ ? {
+ label: "Created",
+ value: serverTimestampToAgoString(input.createdAt) ?? input.createdAt,
+ icon: "time",
+ }
+ : undefined;
+ case "updated_at":
+ return input.updatedAt
+ ? {
+ label: "Updated",
+ value: serverTimestampToAgoString(input.updatedAt) ?? input.updatedAt,
+ icon: "update",
+ }
+ : undefined;
+ case "downloads":
+ return input.downloads !== undefined
+ ? { label: "Downloads", value: formatNumber(input.downloads), icon: "download" }
+ : undefined;
+ }
+};
+
+export async function renderModBadgeSvg(input: ModBadgeSvgInput) {
+ const requestedStats = input.stats?.length ? input.stats : MOD_BADGE_STAT_KEYS;
+ const badge =
+ requestedStats.map((stat) => getBadgeDescriptor(input, stat)).find((stat) => stat !== undefined) ?? {
+ label: "Mod",
+ value: input.modId,
+ icon: "geode" as const,
+ };
+
+ const leftTextWidth = measureTextWidth(badge.label, BADGE_TEXT_SIZE, BADGE_TEXT_WEIGHT);
+ const rightTextWidth = measureTextWidth(badge.value, BADGE_TEXT_SIZE, BADGE_TEXT_WEIGHT);
+ const leftWidth =
+ BADGE_LEFT_PADDING * 2 + BADGE_ICON_SIZE + BADGE_ICON_GAP + leftTextWidth;
+ const rightWidth = Math.max(30, BADGE_RIGHT_PADDING * 2 + rightTextWidth);
+ const totalWidth = leftWidth + rightWidth;
+ const textY = BADGE_HEIGHT / 2;
+ const valueCenterX = leftWidth + rightWidth / 2;
+ const title = `${badge.label}: ${badge.value}`;
+
+ 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 @@