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 ` + + ${escapeXml(title)} + + + + + + + + + + + + + + + + + ${renderIcon(badge.icon, BADGE_LEFT_PADDING, (BADGE_HEIGHT - BADGE_ICON_SIZE) / 2, BADGE_ICON_SIZE, COLORS.muted)} + ${renderBadgeText(badge.label, BADGE_LEFT_PADDING + BADGE_ICON_SIZE + BADGE_ICON_GAP, textY)} + ${renderBadgeText(badge.value, valueCenterX, textY, { anchor: "middle" })} +`; +} 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 @@