Compare commits

...
Author SHA1 Message Date
tdgao e13b26dc6f pnpm prepr 2026-03-26 12:32:34 -06:00
tdgao 039639b1d9 fix table overflow, add game version tags "+ {num}" overflow menu 2026-03-26 12:26:26 -06:00
tdgao 278d74875f fix no modpack loader showing as resource pack loader 2026-03-26 11:38:10 -06:00
Prospector 3c3cde1908 changelog 2026-03-26 00:22:40 -07:00
ProspectorandGitHub 274325d97c fix: settings page error (#5668) 2026-03-26 00:21:19 -07:00
aecsocketandGitHub da48a12551 Only mark servers as offline if they fail pings 3+ times (#5664)
* wip: online status fix

* use INCR

* properly clear cache
2026-03-26 06:34:20 +00:00
François-Xavier TalbotandGitHub bf24ed8d12 Add feature flag to force Archon requests to be traced (#5666) 2026-03-26 06:34:04 +00:00
aecsocketandGitHub 0731654a1c maybe fix daedalus (#5665) 2026-03-26 06:33:58 +00:00
Calum H.andGitHub 81f19eeb8d fix: various content tab hosting bugs (#5662)
* fix: qa

* fix: lint
2026-03-25 17:58:13 +00:00
Calum H.andGitHub 4b4282cfbf fix: 500 on oauth authorize page (#5661) 2026-03-25 17:52:12 +00:00
François-Xavier TalbotandGitHub 7b3471944d hosting: "Reset to onboarding" support-only action (#5659)
* Reset to onboarding button

* Lint

* Intl
2026-03-25 14:50:31 +00:00
Prospector d2abeb434c changelog 2026-03-24 14:21:31 -07:00
François-Xavier TalbotandGitHub 0aecfa3140 hosting: support java 25 (#5654)
* Add Java 25, handle new version format

* Changelog

* Revert "Changelog"

This reverts commit 0c1c7e2fe8.
2026-03-24 20:39:04 +00:00
29 changed files with 481 additions and 85 deletions
@@ -5,7 +5,7 @@
v-if="!isServerProject"
:project="data"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:v3-metadata="projectV3"
:project-v3="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarServerInfo
+4 -4
View File
@@ -114,12 +114,12 @@ pub async fn fetch_neo(
}
let forge_versions = fetch_xml::<Metadata>(
"https://maven.neoforged.net/net/neoforged/forge/maven-metadata.xml",
"https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml",
&semaphore,
)
.await?;
let neo_versions = fetch_xml::<Metadata>(
"https://maven.neoforged.net/net/neoforged/neoforge/maven-metadata.xml",
"https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
&semaphore,
)
.await?;
@@ -133,7 +133,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion {
format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"),
installer_url: format!("https://maven.neoforged.net/releases/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"),
raw: loader_version,
loader_version: version_split,
game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1
@@ -159,7 +159,7 @@ pub async fn fetch_neo(
Ok(ForgeVersion {
format_version: 2,
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"),
installer_url: format!("https://maven.neoforged.net/releases/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"),
loader_version: loader_version.clone(),
raw: loader_version,
game_version,
@@ -38,6 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showProjectPageQuickServerButton: false,
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
archonSentryCapture: false,
hideRussiaCensorshipBanner: false,
disablePrettyProjectUrlRedirects: false,
hidePreviewBanner: false,
+4
View File
@@ -13,6 +13,8 @@ import {
} from '@modrinth/api-client'
import type { Ref } from 'vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
const mod = 'cloudflare:workers'
@@ -28,6 +30,7 @@ export function createModrinthClient(
auth: Ref<{ token: string | undefined }>,
config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string },
): NuxtModrinthClient {
const flags = useFeatureFlags()
const optionalFeatures = [
import.meta.dev ? (new VerboseLoggingFeature() as AbstractFeature) : undefined,
].filter(Boolean) as AbstractFeature[]
@@ -35,6 +38,7 @@ export function createModrinthClient(
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: config.archonBaseUrl,
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
// for modrinth hosting
@@ -1313,6 +1313,9 @@
"hosting.loader.failed-to-repair": {
"message": "Failed to repair server"
},
"hosting.loader.failed-to-reset-to-onboarding": {
"message": "Failed to reset server to onboarding"
},
"hosting.loader.failed-to-save-settings": {
"message": "Failed to save installation settings"
},
@@ -1334,6 +1337,24 @@
"hosting.loader.reset-server-description": {
"message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
},
"hosting.loader.reset-to-onboarding-button": {
"message": "Reset to onboarding"
},
"hosting.loader.reset-to-onboarding-modal-description": {
"message": "This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?"
},
"hosting.loader.reset-to-onboarding-modal-title": {
"message": "Reset to onboarding"
},
"hosting.loader.reset-to-onboarding-success-description": {
"message": "The server has been returned to the onboarding flow."
},
"hosting.loader.reset-to-onboarding-success-title": {
"message": "Server reset to onboarding"
},
"hosting.loader.support-options-title": {
"message": "Support options"
},
"hosting.plan.out-of-stock": {
"message": "Out of stock"
},
+1 -1
View File
@@ -877,7 +877,7 @@
v-if="projectV3Loaded && !isServerProject"
:project="project"
:tags="tags"
:v3-metadata="projectV3"
:project-v3="projectV3"
class="card flex-card experimental-styles-within"
/>
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
@@ -341,7 +341,8 @@
<div v-if="project.project_type !== 'resourcepack'">
<h4>Loaders</h4>
<Categories :categories="version.loaders" :type="project.project_type" />
<span v-if="noModpackLoader">No mod loader</span>
<Categories v-else :categories="version.loaders ?? []" :type="project.project_type" />
</div>
<div>
<h4>Game versions</h4>
@@ -698,6 +699,25 @@ const title = computed(
() => `${isCreating.value ? 'Create Version' : version.value.name} - ${project.value.title}`,
)
const modpackLoaders = computed<string[]>(() => {
if (project.value.project_type !== 'modpack') {
return []
}
if (Array.isArray(version.value.mrpack_loaders) && version.value.mrpack_loaders.length > 0) {
return version.value.mrpack_loaders
}
return (version.value.loaders ?? []).filter((loader: string) => loader !== 'mrpack')
})
const noModpackLoader = computed(
() =>
project.value.project_type === 'modpack' &&
modpackLoaders.value.length === 1 &&
modpackLoaders.value[0] === 'minecraft',
)
const description = computed(
() =>
`Download ${project.value.title} ${
+12 -5
View File
@@ -5,11 +5,11 @@
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
</div>
<p>
<span>{{ error.data.error }}: </span>
{{ error.data.description }}
<span>{{ error.data?.error }}: </span>
{{ error.data?.description }}
</p>
</div>
<div v-else class="oauth-items">
<div v-else-if="app && createdBy && authorizationData" class="oauth-items">
<div class="connected-items">
<div class="profile-pics">
<Avatar size="md" :src="app.icon_url" />
@@ -164,24 +164,31 @@ const {
data: authorizationData,
isPending: pending,
error,
suspense: authSusp,
} = useQuery({
queryKey: computed(() => ['authorization', clientId, redirectUri, scope, state]),
queryFn: getFlowIdAuthorization,
enabled: computed(() => !!clientId && !!redirectUri && !!scope),
})
const { data: app } = useQuery({
const { data: app, suspense: appSusp } = useQuery({
queryKey: computed(() => ['oauth/app', clientId]),
queryFn: () => client.labrinth.oauth_internal.getApp(clientId),
enabled: computed(() => !!clientId),
})
const { data: createdBy } = useQuery({
const { data: createdBy, suspense: userSusp } = useQuery({
queryKey: computed(() => ['user', app.value?.created_by]),
queryFn: () => client.labrinth.users_v2.get(app.value.created_by),
enabled: computed(() => !!app.value?.created_by),
})
onServerPrefetch(async () => {
await authSusp()
await appSusp()
await userSusp()
})
const scopeDefinitions = computed(() =>
scopesToDefinitions(BigInt(authorizationData.value?.requested_scopes || 0)),
)
@@ -1,5 +1,13 @@
<template>
<div class="flex flex-col gap-6 rounded-2xl bg-surface-3 p-6">
<ConfirmModal
ref="resetToOnboardingModal"
:title="formatMessage(messages.resetToOnboardingModalTitle)"
:description="formatMessage(messages.resetToOnboardingModalDescription)"
:proceed-label="formatMessage(messages.resetToOnboardingButton)"
@proceed="confirmResetToOnboarding"
/>
<InstallationSettingsLayout ref="installationSettingsLayout">
<template #extra>
<div class="flex flex-col gap-2.5">
@@ -28,6 +36,24 @@
/>
</template>
</InstallationSettingsLayout>
<div v-if="isSiteAdmin" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.supportOptionsTitle) }}
</span>
<div>
<ButtonStyled color="red">
<button
class="!shadow-none"
:disabled="!worldId || isResettingToOnboarding"
@click="resetToOnboardingModal?.show()"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.resetToOnboardingButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
@@ -37,6 +63,7 @@ import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
formatLoaderLabel,
injectModrinthClient,
@@ -106,6 +133,35 @@ const messages = defineMessages({
id: 'hosting.loader.failed-to-unlink',
defaultMessage: 'Failed to unlink modpack',
},
supportOptionsTitle: {
id: 'hosting.loader.support-options-title',
defaultMessage: 'Support options',
},
resetToOnboardingButton: {
id: 'hosting.loader.reset-to-onboarding-button',
defaultMessage: 'Reset to onboarding',
},
resetToOnboardingModalTitle: {
id: 'hosting.loader.reset-to-onboarding-modal-title',
defaultMessage: 'Reset to onboarding',
},
resetToOnboardingModalDescription: {
id: 'hosting.loader.reset-to-onboarding-modal-description',
defaultMessage:
'This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?',
},
resetToOnboardingSuccessTitle: {
id: 'hosting.loader.reset-to-onboarding-success-title',
defaultMessage: 'Server reset to onboarding',
},
resetToOnboardingSuccessDescription: {
id: 'hosting.loader.reset-to-onboarding-success-description',
defaultMessage: 'The server has been returned to the onboarding flow.',
},
failedToResetToOnboarding: {
id: 'hosting.loader.failed-to-reset-to-onboarding',
defaultMessage: 'Failed to reset server to onboarding',
},
})
const emit = defineEmits<{
@@ -156,8 +212,13 @@ const modpackVersionsQuery = useQuery({
enabled: computed(() => !!modpack.value?.spec.project_id),
})
const auth = await useAuth()
const isSiteAdmin = computed(() => auth.value?.user?.role === 'admin')
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
const editingGameVersion = ref(server.value?.mc_version ?? '')
const resetToOnboardingModal = ref<InstanceType<typeof ConfirmModal>>()
const isResettingToOnboarding = ref(false)
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
@@ -337,11 +398,17 @@ provideInstallationSettings({
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
const platformChanged = platform !== currentPlatform
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
resolvedLoaderVersion = versions[0]?.id ?? null
}
debug('save: emitting reinstall before API call')
emit(
'reinstall',
platformChanged
? { loader: platform, lVersion: loaderVersionId, mVersion: gameVersion }
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
@@ -349,7 +416,7 @@ provideInstallationSettings({
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: loaderVersionId ?? '',
version: resolvedLoaderVersion ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
@@ -590,4 +657,30 @@ function onBrowseModpacks() {
query: { sid: serverId, from: 'reset-server', wid: worldId.value },
})
}
async function confirmResetToOnboarding() {
if (!worldId.value) return
try {
isResettingToOnboarding.value = true
await client.archon.servers_v1.resetToOnboarding(serverId, worldId.value)
server.value.flows = { intro: true }
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] }),
])
addNotification({
type: 'success',
title: formatMessage(messages.resetToOnboardingSuccessTitle),
text: formatMessage(messages.resetToOnboardingSuccessDescription),
})
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToResetToOnboarding),
})
} finally {
isResettingToOnboarding.value = false
}
}
</script>
@@ -150,6 +150,7 @@ const JAVA_VERSIONS = [
{ value: 11, label: 'Java 11' },
{ value: 17, label: 'Java 17' },
{ value: 21, label: 'Java 21' },
{ value: 25, label: 'Java 25' },
]
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
@@ -203,19 +204,50 @@ const hasUnsavedChanges = computed(
// Java version filtering
const showAllVersions = ref(false)
type MinecraftReleaseVersion = {
major: number
minor: number
}
function parseMinecraftReleaseVersion(version: string): MinecraftReleaseVersion | null {
const [majorPart, minorPart] = version.split('.')
if (!majorPart || !minorPart) return null
const major = Number(majorPart)
const minor = Number(minorPart)
if (!Number.isInteger(major) || !Number.isInteger(minor)) return null
return { major, minor }
}
function filterJavaVersions(compatibleVersions: number[]) {
return JAVA_VERSIONS.filter((version) => compatibleVersions.includes(version.value))
}
const displayedJavaVersions = computed(() => {
if (showAllVersions.value) return JAVA_VERSIONS
const mcVersion = server.value?.mc_version ?? ''
if (!mcVersion) return JAVA_VERSIONS
const [, minor] = mcVersion.split('.').map(Number)
const releaseVersion = parseMinecraftReleaseVersion(mcVersion)
if (!releaseVersion) return JAVA_VERSIONS
if (minor >= 20) return JAVA_VERSIONS.filter((v) => v.value === 21)
if (minor >= 17) return JAVA_VERSIONS.filter((v) => [17, 21].includes(v.value))
if (minor >= 12) return JAVA_VERSIONS
if (minor >= 6) return JAVA_VERSIONS.filter((v) => [8, 11].includes(v.value))
return JAVA_VERSIONS.filter((v) => v.value === 8)
if (releaseVersion.major > 1) {
if (releaseVersion.major >= 26) {
return filterJavaVersions([25])
}
return JAVA_VERSIONS
}
if (releaseVersion.minor >= 20) return filterJavaVersions([21])
if (releaseVersion.minor >= 17) return filterJavaVersions([17, 21])
if (releaseVersion.minor >= 12) return filterJavaVersions([8, 11, 17, 21])
if (releaseVersion.minor >= 6) return filterJavaVersions([8, 11])
return filterJavaVersions([8])
})
// Save mutation
+4 -4
View File
@@ -196,10 +196,6 @@ import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
useHead({
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
})
const messages = defineMessages({
headTitle: {
id: 'settings.head-title',
@@ -219,6 +215,10 @@ const developerModeBanner = defineMessages({
},
})
useHead({
title: () => `${formatMessage(messages.headTitle)} - Modrinth`,
})
const layoutMode = defineMessages({
rows: {
id: 'settings.display.project-list-layouts.mode.rows',
@@ -29,7 +29,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -45,7 +46,7 @@
false,
true,
false,
false
true
]
},
"hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17"
@@ -44,7 +44,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -79,7 +80,7 @@
true,
false,
false,
false,
true,
null
]
},
@@ -25,7 +25,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
@@ -22,7 +22,8 @@
"low",
"medium",
"high",
"severe"
"severe",
"malware"
]
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ When the user refers to "perform[ing] pre-PR checks", do the following:
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare`
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
- NEVER run `cargo sqlx prepare --workspace`
## Testing
+14
View File
@@ -788,6 +788,20 @@ impl RedisConnection {
.await?;
Ok(values)
}
#[tracing::instrument(skip(self))]
pub async fn incr(
&mut self,
namespace: &str,
id: &str,
) -> Result<Option<u64>, DatabaseError> {
let key = format!("{}_{namespace}:{id}", self.meta_namespace);
let value = cmd("INCR")
.arg(key)
.query_async(&mut self.connection)
.await?;
Ok(value)
}
}
#[derive(Serialize, Deserialize)]
+1
View File
@@ -293,4 +293,5 @@ vars! {
SERVER_PING_RETRIES: usize = 3usize;
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64;
}
+57 -18
View File
@@ -24,6 +24,7 @@ pub struct ServerPingQueue {
}
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping";
pub const REDIS_FAILURE_NAMESPACE: &str = "minecraft_java_server_ping_failures";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
@@ -118,27 +119,65 @@ impl ServerPingQueue {
.await
.wrap_err("failed to write ping record")?;
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
let mut updated_project = false;
if data.is_some() {
// ping succeeded; immediately update its online status in redis
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err("failed to set redis key")?;
updated_project = true;
redis
.delete(REDIS_FAILURE_NAMESPACE, project_id)
.await
.wrap_err("failed to delete failure count")?;
} else {
// ping failed; if it's failed too many times, mark it as offline in redis
// otherwise, just add to the fail counter
let failure_count = redis
.incr(REDIS_FAILURE_NAMESPACE, &project_id.to_string())
.await
.wrap_err("failed to increment failure count")?;
if let Some(count) = failure_count
&& count >= ENV.SERVER_PING_MAX_FAIL_COUNT
{
redis
.set_serialized_to_json(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.await
.wrap_err(
"failed to set failed ping record in redis",
)?;
updated_project = true;
}
}
if updated_project {
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.wrap_err("failed to set redis key")?;
DBProject::clear_cache(
(*project_id).into(),
None,
None,
&self.redis,
)
.await
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
.inspect_err(|err| {
warn!("failed to clear project cache: {err:#}")
})
.ok();
}
}
ch.end()
@@ -126,6 +126,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const headers = mergedOptions.headers
if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
@@ -309,6 +310,21 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
return headers
}
protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
return
}
options.headers['modrinth-sentry-capture'] = '1'
}
private shouldCaptureArchonRequests(): boolean {
const archonSentryCapture = this.config.archonSentryCapture
return typeof archonSentryCapture === 'function'
? archonSentryCapture()
: archonSentryCapture === true
}
/**
* Execute the actual HTTP request
*
@@ -54,4 +54,16 @@ export class ArchonServersV1Module extends AbstractModule {
method: 'DELETE',
})
}
/**
* Reset a world to onboarding
* POST /v1/servers/:id/worlds/:wid/onboard
*/
public async resetToOnboarding(serverId: string, worldId: string): Promise<void> {
await this.client.request(`/servers/${serverId}/worlds/${worldId}/onboard`, {
api: 'archon',
version: 1,
method: 'POST',
})
}
}
@@ -593,6 +593,7 @@ export namespace Labrinth {
categories: string[]
additional_categories: string[]
loaders: string[]
mrpack_loaders: string[]
versions: string[]
icon_url?: string
link_urls: Record<string, Link>
@@ -46,6 +46,7 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
...options.headers,
},
}
this.attachArchonSentryCaptureHeader(mergedOptions)
const context = this.buildUploadContext(url, path, mergedOptions)
+8
View File
@@ -55,6 +55,14 @@ export interface ClientConfig {
*/
headers?: Record<string, string>
/**
* Whether to attach `modrinth-sentry-capture: 1` to Archon requests.
* Can be a callback so apps can drive this from runtime feature flags.
*
* @default false
*/
archonSentryCapture?: boolean | (() => boolean)
/**
* Features to enable for this client
* Features are applied in the order they appear in this array
+22
View File
@@ -10,6 +10,28 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-03-26T07:22:22+00:00`,
product: 'web',
body: `## Fixed
- Fixed error on settings page.
- Fixed the "500 Server Error" error on the "Authorized apps" settings page.`,
},
{
date: `2026-03-26T07:22:22+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed wrong mod being disabled on hosted servers - Disabling a non-Modrinth mod could incorrectly disable a different mod instead
- Fixed content list not refreshing after installing mods - Navigating back to the content tab after installing something from Browse would show stale data for up to 10 seconds
- Fixed copying mod filenames inserting a newline - Copying a filename or version number from the content tab no longer includes a line break in the middle
- Fixed NeoForge installs sending an empty loader version - Changing to NeoForge on a hosted server could send a blank loader version, resulting in a broken installation`,
},
{
date: `2026-03-24T21:14:30-08:00`,
product: 'hosting',
body: `## Added
- Added support for Java 25.`,
},
{
date: `2026-03-17T19:30:00-08:00`,
product: 'web',
@@ -3,7 +3,7 @@
<div class="flex flex-wrap justify-between gap-2">
<VersionFilterControl
ref="versionFilters"
:versions="versions"
:versions="normalizedVersions"
:game-versions="gameVersions"
:base-id="`${baseId}-filter`"
@update:query="updateQuery"
@@ -110,13 +110,18 @@
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center"
class="pointer-events-none relative z-[1] flex flex-col justify-center overflow-hidden min-w-32"
:class="{
'group-hover:underline': !!versionLink,
}"
title="`${version.version_number} - ${version.name}`"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
<div class="font-bold text-contrast text-ellipsis overflow-hidden">
{{ version.version_number }}
</div>
<div class="text-xs font-medium text-ellipsis overflow-hidden">
{{ version.name }}
</div>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
@@ -127,7 +132,7 @@
v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
)"
).slice(0, maxGameVersionTags)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="z-[1]"
@@ -137,21 +142,61 @@
>
{{ gameVersion }}
</TagItem>
<Menu
v-if="
formatVersionsForDisplay(version.game_versions, gameVersions).length >
maxGameVersionTags
"
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="z-[1] cursor-default"
>
<TagItem tabindex="0">
+{{
formatVersionsForDisplay(version.game_versions, gameVersions).length -
maxGameVersionTags
}}
</TagItem>
<template #popper>
<div class="flex gap-1 flex-wrap max-w-[20rem]">
<TagItem
v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
).slice(maxGameVersionTags)"
:key="`overflow-version-tag-${gameVersion}`"
:action="
() =>
versionFilters?.toggleFilters('gameVersion', version.game_versions)
"
>
{{ gameVersion }}
</TagItem>
</div>
</template>
</Menu>
</div>
</div>
<div class="flex items-center">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="z-[1]"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
<template v-if="version.noModLoader">
<TagItem class="z-[1] border !border-solid border-surface-5">
No mod loader
</TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="z-[1]"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</template>
</div>
</div>
<div v-if="hasMultipleEnvironments" class="flex items-center">
@@ -162,7 +207,7 @@
class="z-[1] text-center"
>
<component :is="tag.icon" />
{{ formatMessage(tag.label) }}
{{ formatMessage(tag.label).replace('and', '&') }}
</TagItem>
</div>
</div>
@@ -233,6 +278,7 @@ import {
type GameVersionTag,
type Version,
} from '@modrinth/utils'
import { Menu } from 'floating-vue'
import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -251,6 +297,11 @@ const formatDateTime = useFormatDateTime({
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
}
type DisplayVersion = VersionWithDisplayUrlEnding & {
noModLoader: boolean
}
const props = withDefaults(
@@ -278,6 +329,39 @@ const props = withDefaults(
},
)
function getModpackLoaders(version: VersionWithDisplayUrlEnding): string[] {
if (props.project.project_type !== 'modpack') {
return version.loaders
}
if (version.mrpack_loaders?.length) {
return version.mrpack_loaders
}
return version.loaders.filter((loader) => loader !== 'mrpack')
}
function hasNoModLoader(loaders: string[]): boolean {
return (
props.project.project_type === 'modpack' && loaders.length === 1 && loaders[0] === 'minecraft'
)
}
const normalizedVersions = computed<DisplayVersion[]>(() =>
props.versions.map((version) => {
const loaders = getModpackLoaders(version)
const noModLoader = hasNoModLoader(loaders)
return {
...version,
loaders: noModLoader ? [] : loaders,
noModLoader,
}
}),
)
const maxGameVersionTags = 6
const currentPage: Ref<number> = ref(1)
const pageSize: Ref<number> = ref(20)
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
@@ -296,7 +380,7 @@ const hasMultipleEnvironments = computed(() => {
})
const filteredVersions = computed(() => {
return props.versions.filter(
return normalizedVersions.value.filter(
(version) =>
hasAnySelected(version.game_versions, selectedGameVersions.value) &&
hasAnySelected(version.loaders, selectedPlatforms.value) &&
@@ -15,15 +15,22 @@
<section v-if="project.project_type !== 'resourcepack'" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.platforms) }}</h3>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
<template v-if="noModpackLoader">
<TagItem class="border !border-solid border-surface-5 hover:no-underline">
No mod loader
</TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</template>
</div>
</section>
<section v-if="showEnvironments" class="flex flex-col gap-2">
@@ -85,6 +92,7 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ClientIcon,
getLoaderIcon,
@@ -93,7 +101,7 @@ import {
UserIcon,
} from '@modrinth/assets'
import { FormattedTag, TagItem } from '@modrinth/ui'
import type { EnvironmentV3, GameVersionTag, PlatformTag, ProjectV3Partial } from '@modrinth/utils'
import type { EnvironmentV3, GameVersionTag, PlatformTag } from '@modrinth/utils'
import { getVersionsToDisplay } from '@modrinth/utils'
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -128,17 +136,24 @@ const props = defineProps<{
gameVersions: GameVersionTag[]
loaders: PlatformTag[]
}
v3Metadata?: ProjectV3Partial
projectV3?: Labrinth.Projects.v3.Project
}>()
const noModpackLoader = computed(
() =>
props.projectV3?.project_types.includes('modpack') &&
props.projectV3?.mrpack_loaders.length === 1 &&
props.projectV3?.mrpack_loaders[0] === 'minecraft',
)
const showEnvironments = computed(
() =>
TYPES_WITH_ENVS.some((x) => props.v3Metadata?.project_types.includes(x)) &&
TYPES_WITH_ENVS.some((x) => props.projectV3?.project_types.includes(x)) &&
primaryEnvironment.value,
)
const primaryEnvironment = computed<EnvironmentV3 | undefined>(() =>
props.v3Metadata?.environment?.find((x) => x !== 'unknown'),
props.projectV3?.environment?.find((x) => x !== 'unknown'),
)
type EnvironmentTag = {
@@ -212,8 +212,8 @@ const deleteHovered = ref(false)
>
<span ref="versionNumberRef" class="truncate">{{
version.version_number.slice(0, Math.ceil(version.version_number.length / 2))
}}</span>
<span class="shrink-0">{{
}}</span
><span class="shrink-0">{{
version.version_number.slice(Math.ceil(version.version_number.length / 2))
}}</span>
</AutoLink>
@@ -223,8 +223,8 @@ const deleteHovered = ref(false)
>
<span ref="fileNameRef" class="truncate">{{
version.file_name.slice(0, Math.ceil(version.file_name.length / 2))
}}</span>
<span class="shrink-0">{{
}}</span
><span class="shrink-0">{{
version.file_name.slice(Math.ceil(version.file_name.length / 2))
}}</span>
</span>
@@ -115,6 +115,7 @@ const contentQuery = useQuery({
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
staleTime: 0,
})
const modpackProjectId = computed(() => contentQuery.data.value?.modpack?.spec.project_id ?? null)
@@ -483,7 +484,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
link: `/${addon.owner.type}/${addon.owner.id}`,
}
: undefined,
id: addon.id,
id: addon.id ?? addon.filename,
enabled: !addon.disabled,
file_name: addon.filename,
project_type: addon.kind,