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 ` + + Geode mod badge + + + + ${parts.join("")} +`; +} 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 @@