Merge tag 'v0.14.7' into beta

v0.14.7
This commit is contained in:
2026-06-18 03:27:02 +03:00
65 changed files with 2223 additions and 355 deletions
@@ -89,14 +89,9 @@ const initFiles = async () => {
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric'),
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
}))
.filter(
(pathData) =>
!pathData.path.includes('.DS_Store') &&
pathData.path !== 'mods/.connector' &&
!pathData.path.startsWith('mods/.connector/'),
)
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
+72 -22
View File
@@ -29,7 +29,7 @@ import {
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
@@ -41,6 +41,7 @@ import {
get_search_results_v3,
get_version_many,
} from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import {
get as getInstance,
@@ -181,6 +182,28 @@ watchServerContextChanges()
await initInstanceContext()
async function refreshInstalledProjectIds() {
if (!route.query.i) return
if (route.query.from === 'worlds') {
const worlds = await get_profile_worlds(route.query.i as string).catch(handleError)
if (!worlds) return
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
return
}
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
if (!ids) return
debugLog('installedProjectIds loaded', { count: ids.length })
installedProjectIds.value = ids
}
async function initInstanceContext() {
debugLog('initInstanceContext', {
queryI: route.query.i,
@@ -199,24 +222,7 @@ async function initInstanceContext() {
gameVersion: instance.value?.game_version,
})
if (route.query.from === 'worlds') {
get_profile_worlds(route.query.i as string)
.then((worlds) => {
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
})
.catch(handleError)
} else {
getInstalledProjectIds(route.query.i as string)
.then((ids) => {
debugLog('installedProjectIds loaded', { count: ids?.length })
installedProjectIds.value = ids
})
.catch(handleError)
}
await refreshInstalledProjectIds()
if (instance.value?.linked_data?.project_id) {
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
@@ -805,10 +811,10 @@ function getCardActions(
selectedInstall.versionId,
instance.value ? instance.value.path : null,
'SearchCard',
(versionId) => {
(versionId, installedProjectIds) => {
setProjectInstalling(projectResult.project_id, false)
if (versionId) {
onSearchResultInstalled(projectResult.project_id)
onSearchResultsInstalled(installedProjectIds ?? [projectResult.project_id])
}
},
(profile) => {
@@ -834,7 +840,19 @@ function onSearchResultInstalled(id: string) {
markServerProjectInstalled(id)
return
}
newlyInstalled.value.push(id)
if (!newlyInstalled.value.includes(id)) {
newlyInstalled.value = [...newlyInstalled.value, id]
}
}
function onSearchResultsInstalled(ids: string[]) {
if (isServerContext.value) {
for (const id of ids) {
markServerProjectInstalled(id)
}
return
}
newlyInstalled.value = Array.from(new Set([...newlyInstalled.value, ...ids]))
}
async function search(requestParams: string) {
@@ -966,6 +984,38 @@ if (instance.value?.game_version) {
await searchState.refreshSearch()
type UnlistenFn = () => void
let isUnmounted = false
let unlistenProfiles: UnlistenFn | null = null
onMounted(() => {
profile_listener(async (event: { event: string; profile_path_id: string }) => {
if (
instance.value &&
event.profile_path_id === instance.value.path &&
event.event === 'synced'
) {
await refreshInstalledProjectIds()
await searchState.refreshSearch()
}
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenProfiles = unlisten
})
.catch(handleError)
})
onUnmounted(() => {
isUnmounted = true
unlistenProfiles?.()
})
function getProjectBrowseQuery() {
if (!installContext.value) return undefined
return {
+68 -7
View File
@@ -99,7 +99,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache.js'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
@@ -451,6 +451,63 @@ async function removeMod(mod: ContentItem) {
}
}
function isBreakingDependency(dependency: Labrinth.Versions.v2.Dependency) {
return dependency.dependency_type === 'required' || dependency.dependency_type === 'embedded'
}
function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item: ContentItem) {
return (
(!!dependency.project_id && dependency.project_id === item.project?.id) ||
('version_id' in dependency &&
!!dependency.version_id &&
dependency.version_id === item.version?.id)
)
}
async function getDeleteDependencyWarning(items: ContentItem[]) {
if (props.isServerInstance) return null
const deletingIds = new Set(items.map(getContentItemId))
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
const versionIds = [
...new Set(remainingItems.map((item) => item.version?.id).filter((id): id is string => !!id)),
]
if (versionIds.length === 0) return null
const versions = (await get_version_many(versionIds).catch((err) => {
handleError(err as Error)
return null
})) as Labrinth.Versions.v2.Version[] | null
if (!versions) return null
const versionsById = new Map(versions.map((version) => [version.id, version]))
const dependents = remainingItems
.map((candidate) => {
const version = candidate.version?.id ? versionsById.get(candidate.version.id) : null
if (!version) return null
const dependencies = items.filter((item) => {
if (!item.project?.id && !item.version?.id) return false
return version.dependencies?.some(
(dependency) =>
isBreakingDependency(dependency) && dependencyTargetsItem(dependency, item),
)
})
return dependencies.length > 0 ? { item: candidate, dependencies } : null
})
.filter(
(dependent): dependent is { item: ContentItem; dependencies: ContentItem[] } =>
dependent !== null,
)
return dependents.length > 0 ? { items, dependents } : null
}
async function updateProject(mod: ContentItem) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
@@ -481,6 +538,7 @@ async function updateProject(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
throw err
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
@@ -885,13 +943,15 @@ async function handleModalUpdate(
} else if (updatingProject.value) {
const mod = updatingProject.value
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
await updateProject(mod)
} else {
await switchProjectVersion(mod, selectedVersion)
try {
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
await updateProject(mod)
} else {
await switchProjectVersion(mod, selectedVersion)
}
} finally {
resetUpdateState()
}
resetUpdateState()
}
}
@@ -1081,6 +1141,7 @@ provideContentManager({
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
+18 -3
View File
@@ -406,6 +406,20 @@ function buildProjectHref(path, extraQuery = {}) {
return qs ? `${path}?${qs}` : path
}
function buildBrowseHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (key === 'b') continue
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
const projectDescriptionHref = computed(() => buildProjectHref(`/project/${route.params.id}`))
const versionsHref = computed(() =>
buildProjectHref(`/project/${route.params.id}/versions`, instanceFilters.value),
@@ -416,7 +430,7 @@ const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
return `/browse/${type}`
return buildBrowseHref(`/browse/${type}`)
})
const projectInstallContext = computed(() => {
@@ -725,10 +739,11 @@ async function install(version) {
version,
instance.value ? instance.value.path : null,
'ProjectPage',
(version) => {
(version, installedProjectIds) => {
installing.value = false
if (instance.value && version) {
const installedIds = installedProjectIds ?? [data.value.id]
if (instance.value && version && installedIds.includes(data.value.id)) {
installed.value = true
installedVersion.value = version
}
@@ -5,7 +5,7 @@
:current-title="version.name"
:link-stack="[
{
href: `/project/${route.params.id}/versions`,
href: buildProjectHref(`/project/${route.params.id}/versions`),
label: 'Versions',
},
]"
@@ -249,6 +249,19 @@ const author = computed(() =>
const displayDependencies = ref({})
function buildProjectHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
async function refreshDisplayDependencies() {
const projectIds = new Set()
const versionIds = new Set()
@@ -282,7 +295,7 @@ async function refreshDisplayDependencies() {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
link: `/project/${project.slug}/version/${version.id}`,
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
}
} else {
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
@@ -292,7 +305,7 @@ async function refreshDisplayDependencies() {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `${dependency.dependency_type}`,
link: `/project/${project.slug}`,
link: buildProjectHref(`/project/${project.slug}`),
}
} else {
return {
@@ -5,7 +5,7 @@
:game-versions="gameVersions"
:versions="versions"
:project="project"
:version-link="(version) => `/project/${project.id}/version/${version.id}`"
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
@@ -73,6 +73,7 @@ import {
ProjectPageVersions,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
@@ -109,6 +110,20 @@ defineProps({
})
const { handleError } = injectNotificationManager()
const route = useRoute()
function buildProjectHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
@@ -42,6 +42,8 @@ interface ModpackAlreadyInstalledModalRef {
show: (instanceName: string, instancePath: string) => void
}
export type ContentInstallCallback = (versionId?: string, installedProjectIds?: string[]) => void
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
@@ -102,7 +104,7 @@ export interface ContentInstallContext {
versionId?: string | null,
instancePath?: string | null,
source?: string,
callback?: (versionId?: string) => void,
callback?: ContentInstallCallback,
createInstanceCallback?: (profile: string) => void,
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) => Promise<void>
@@ -256,25 +258,25 @@ export function createContentInstall(opts: {
let incompatibilityWarningModalRef: ModalRef | null = null
let currentProject: Labrinth.Projects.v2.Project | null = null
let currentVersions: Labrinth.Versions.v2.Version[] = []
let currentCallback: (versionId?: string) => void = () => {}
let currentCallback: ContentInstallCallback = () => {}
let profileMap: Record<string, GameInstance> = {}
let incompatibilityWarningInstance: GameInstance | null = null
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
let incompatibilityWarningCallback: ContentInstallCallback = () => {}
let incompatibilityWarningInstalled = false
let pendingModpackInstall: {
project: Labrinth.Projects.v2.Project
version: string
source: string
callback: (versionId?: string) => void
callback: ContentInstallCallback
createInstanceCallback: (profile: string) => void
} | null = null
async function showModInstallModal(
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
onInstall: (versionId?: string) => void,
onInstall: ContentInstallCallback,
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) {
currentProject = project
@@ -440,7 +442,7 @@ export function createContentInstall(opts: {
if (versionId && storeInstance) {
storeInstance.installed = true
}
currentCallback(versionId)
currentCallback(versionId, versionId && currentProject ? [currentProject.id] : undefined)
}
await showIncompatibilityWarning(
profile,
@@ -487,7 +489,7 @@ export function createContentInstall(opts: {
title: currentProject!.title,
source: 'ProjectInstallModal',
})
currentCallback(version.id)
currentCallback(version.id, installedProjectIds)
} catch (err) {
if (storeInstance) storeInstance.installing = false
opts.handleError(err)
@@ -501,7 +503,7 @@ export function createContentInstall(opts: {
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: (versionId?: string) => void,
callback: ContentInstallCallback,
) {
incompatibilityWarningInstance = instance
incompatibilityWarningProject = project
@@ -542,7 +544,7 @@ export function createContentInstall(opts: {
incompatibilityWarningInstalling.value = false
incompatibilityWarningInstalled = true
incompatibilityWarningCallback(version.id)
incompatibilityWarningCallback(version.id, [incompatibilityWarningProject.id])
incompatibilityWarningModalRef?.hide()
trackEvent('ProjectInstall', {
@@ -630,7 +632,7 @@ export function createContentInstall(opts: {
versionId?: string | null,
instancePath?: string | null,
source: string = 'unknown',
callback: (versionId?: string) => void = () => {},
callback: ContentInstallCallback = () => {},
createInstanceCallback: (profile: string) => void = () => {},
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) {
@@ -714,7 +716,7 @@ export function createContentInstall(opts: {
title: project.title,
source,
})
callback(version.id)
callback(version.id, installedProjectIds)
} finally {
removeInstallingItems(instancePath, installedProjectIds)
}
@@ -0,0 +1,189 @@
<template>
<div class="relative overflow-clip rounded-xl bg-bg px-4 py-3">
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="
charge.type === 'refund' ? 'bg-purple' : (chargeStatuses[charge.status]?.color ?? 'bg-blue')
"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<span>
<span class="font-bold text-contrast">
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
<template v-else-if="charge.status === 'failed'"> Failed </template>
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else-if="charge.status === 'expiring'"> Expiring </template>
<template v-else> {{ charge.status }} </template>
</span>
<span class="text-secondary opacity-50"></span>
<span>
<template v-if="charge.type === 'refund'"> Refund </template>
<template v-else-if="charge.type === 'subscription'">
<template v-if="charge.status === 'cancelled'"> Subscription </template>
<template v-else-if="isLatestCharge"> Started subscription </template>
<template v-else> Subscription renewal </template>
</template>
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
<template v-else> {{ charge.status }} </template>
</span>
<template v-if="charge.status !== 'cancelled'">
<span class="text-secondary opacity-50"></span>
{{ formatPrice(charge.amount, charge.currency_code) }}
</template>
</span>
<span
v-if="productMetadata && productMetadata.type === 'pyro'"
class="flex items-center gap-1 text-sm text-secondary"
>
<span class="font-bold">Product:</span>
<span v-if="productMetadata.ram">{{ productMetadata.ram / 1024 }}GB RAM</span>
<span v-else>Unknown RAM</span>
<span class="text-secondary opacity-50"></span>
<span v-if="productMetadata.cpu">{{ productMetadata.cpu }} vCPU</span>
<span v-else>Unknown CPU</span>
<span class="text-secondary opacity-50"></span>
<span v-if="productMetadata.storage">{{ productMetadata.storage / 1024 }}GB Storage</span>
<span v-else>Unknown Storage</span>
<span class="text-secondary opacity-50"></span>
<span v-if="productMetadata.swap">{{ productMetadata.swap }}MB Swap</span>
<span v-else>Unknown Swap</span>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && dayjs(charge.due).isBefore(dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ formatDateTime(charge.due) }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ formatDateTime(charge.last_attempt) }}
<span class="text-secondary">({{ formatRelativeTime(charge.last_attempt) }}) </span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
<span class="text-secondary opacity-50"></span>
{{ charge.type }}
<span class="text-secondary opacity-50"></span>
{{ formatPrice(charge.amount, charge.currency_code) }}
<span class="text-secondary opacity-50"></span>
{{ formatDateTimeShort(charge.due) }}
<template v-if="charge.subscription_interval">
<span class="text-secondary opacity-50"></span>
{{ charge.subscription_interval }}
</template>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled v-if="isRefunded">
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
color="red"
color-fill="text"
>
<button @click="emit('refund', charge)">
<CurrencyIcon />
Refund options
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'failed' || charge.status === 'open'"
color="red"
color-fill="text"
>
<button @click="emit('modify', charge, subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, CurrencyIcon } from '@modrinth/assets'
import { ButtonStyled, useFormatDateTime, useFormatPrice, useRelativeTime } from '@modrinth/ui'
import dayjs from 'dayjs'
import { products } from '~/generated/state.json'
const props = defineProps<{
charge: Labrinth.Billing.Internal.Charge
subscription: Labrinth.Billing.Internal.UserSubscription
allCharges: Labrinth.Billing.Internal.Charge[]
chargeIndex: number
chargeCount: number
}>()
const emit = defineEmits<{
refund: [charge: Labrinth.Billing.Internal.Charge]
modify: [
charge: Labrinth.Billing.Internal.Charge,
subscription: Labrinth.Billing.Internal.UserSubscription,
]
}>()
const formatPrice = useFormatPrice()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatDateTimeShort = useFormatDateTime({
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
const formatRelativeTime = useRelativeTime()
const isLatestCharge = computed(() => props.chargeIndex === props.chargeCount - 1)
const isRefunded = computed(() =>
props.allCharges.some(
(charge) => charge.type === 'refund' && charge.parent_charge_id === props.charge.id,
),
)
const productMetadata = computed(
() =>
products.find((product) => product.prices.some((price) => price.id === props.charge.price_id))
?.metadata,
)
const chargeStatuses = {
open: {
color: 'bg-blue',
},
processing: {
color: 'bg-orange',
},
succeeded: {
color: 'bg-green',
},
failed: {
color: 'bg-red',
},
cancelled: {
color: 'bg-red',
},
expiring: {
color: 'bg-orange',
},
}
</script>
@@ -132,23 +132,39 @@ export function createLoaderParsers(
),
}
},
// Fabric
// Fabric (or Babric for mc version beta 1.7.3)
'fabric.mod.json': (file: string): InferredVersionInfo => {
const metadata = JSON.parse(file) as any
const detectedGameVersions = metadata.depends
const mcDependency = metadata.depends?.minecraft
const mcDependencies = Array.isArray(mcDependency) ? mcDependency : [mcDependency]
let detectedGameVersions = metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: []
const loaders: string[] = []
// Detect Beta 1.7.3 -> Babric
const hasBabricVersion = mcDependencies.some(
(version: string | undefined) => version?.includes('1.0.0-beta.7.3'), // this is fabric's normalized mc version format
)
// Detect 1.3-1.13 -> legacy-fabric
const hasLegacyVersions = detectedGameVersions.some((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
if (hasLegacyVersions) loaders.push('legacy-fabric')
else loaders.push('fabric')
if (hasBabricVersion) {
loaders.push('babric')
detectedGameVersions = gameVersions
.filter((version) => version.version === 'b1.7.3')
.map((version) => version.version)
} else if (hasLegacyVersions) {
loaders.push('legacy-fabric')
} else {
loaders.push('fabric')
}
return {
name: `${project.title} ${metadata.version}`,
@@ -50,15 +50,15 @@ export function getGameVersionsMatchingMavenRange(
ranges.push(range)
}
const LESS_THAN_EQUAL = /^\(,(.*)]$/
const LESS_THAN = /^\(,(.*)\)$/
const EQUAL = /^\[(.*)]$/
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
const GREATER_THAN = /^\((.*),\)$/
const BETWEEN = /^\((.*),(.*)\)$/
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
const LESS_THAN_EQUAL = /^\(,([^,]*)]$/
const LESS_THAN = /^\(,([^,]*)\)$/
const EQUAL = /^\[([^,]*)]$/
const GREATER_THAN_EQUAL = /^\[([^,]*),\)$/
const GREATER_THAN = /^\(([^,]*),\)$/
const BETWEEN = /^\(([^,]*),([^,]*)\)$/
const BETWEEN_EQUAL = /^\[([^,]*),([^,]*)]$/
const BETWEEN_LESS_THAN_EQUAL = /^\(([^,]*),([^,]*)]$/
const BETWEEN_GREATER_THAN_EQUAL = /^\[([^,]*),([^,]*)\)$/
const semverRanges = []
@@ -198,115 +198,17 @@
</div>
</div>
<div class="flex flex-col gap-2">
<div
<AdminBillingChargeCard
v-for="(charge, index) in subscription.charges"
:key="charge.id"
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
>
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="
charge.type === 'refund'
? 'bg-purple'
: (chargeStatuses[charge.status]?.color ?? 'bg-blue')
"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<span>
<span class="font-bold text-contrast">
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
<template v-else-if="charge.status === 'failed'"> Failed </template>
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else-if="charge.status === 'expiring'"> Expiring </template>
<template v-else> {{ charge.status }} </template>
</span>
<span>
<template v-if="charge.type === 'refund'"> Refund </template>
<template v-else-if="charge.type === 'subscription'">
<template v-if="charge.status === 'cancelled'"> Subscription </template>
<template v-else-if="index === subscription.charges.length - 1">
Started subscription
</template>
<template v-else> Subscription renewal </template>
</template>
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
<template v-else> {{ charge.status }} </template>
</span>
<template v-if="charge.status !== 'cancelled'">
{{ formatPrice(charge.amount, charge.currency_code) }}
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ formatDateTime(charge.due) }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ formatDateTime(charge.last_attempt) }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
{{ charge.type }}
{{ formatPrice(charge.amount, charge.currency_code) }}
{{ formatDateTimeShort(charge.due) }}
<template v-if="charge.subscription_interval">
{{ charge.subscription_interval }}
</template>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled
v-if="
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
"
>
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
color="red"
color-fill="text"
>
<button @click="showRefundModal(charge)">
<CurrencyIcon />
Refund options
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'failed' || charge.status === 'open'"
color="red"
color-fill="text"
>
<button @click="showModifyModal(charge, subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div>
</div>
</div>
:charge="charge"
:subscription="subscription"
:all-charges="charges"
:charge-index="index"
:charge-count="subscription.charges.length"
@refund="showRefundModal"
@modify="showModifyModal"
/>
</div>
</div>
</div>
@@ -334,7 +236,6 @@ import {
StyledInput,
Toggle,
useFormatDateTime,
useFormatPrice,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -344,21 +245,14 @@ import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue'
import AdminBillingChargeCard from '~/components/ui/admin/AdminBillingChargeCard.vue'
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
const formatPrice = useFormatPrice()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatDateTimeShort = useFormatDateTime({
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
const vintl = useVIntl()
@@ -372,15 +266,15 @@ const messages = defineMessages({
},
})
const chargeId = useRouteId('charge')
const userId = useRouteId('user')
const {
data: user,
error: userError,
suspense: userSuspense,
} = useQuery({
queryKey: ['user', chargeId],
queryFn: () => labrinth.users_v2.get(chargeId),
queryKey: ['user', userId],
queryFn: () => labrinth.users_v2.get(userId),
})
onServerPrefetch(userSuspense)
@@ -533,27 +427,6 @@ async function modifyCharge() {
}
modifying.value = false
}
const chargeStatuses = {
open: {
color: 'bg-blue',
},
processing: {
color: 'bg-orange',
},
succeeded: {
color: 'bg-green',
},
failed: {
color: 'bg-red',
},
cancelled: {
color: 'bg-red',
},
expiring: {
color: 'bg-orange',
},
}
</script>
<style scoped>
.page {
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { GitGraphIcon, RssIcon } from '@modrinth/assets'
import { articles as rawArticles } from '@modrinth/blog'
import { Avatar, ButtonStyled, injectModrinthClient, useFormatDateTime } from '@modrinth/ui'
import {
ArticleBody,
Avatar,
ButtonStyled,
injectModrinthClient,
useFormatDateTime,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, onMounted } from 'vue'
@@ -169,7 +175,7 @@ onMounted(() => {
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
:alt="article.title"
/>
<div class="markdown-body" v-html="article.html" />
<ArticleBody :html="article.html" />
<h3
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
>
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Modrinth joins Spark Universe",
"summary": "The next chapter. What it means and why we think its right for Modrinth.",
"thumbnail": "https://modrinth.com/news/article/joining-spark-universe/thumbnail.webp",
"date": "2026-06-15T14:00:00.000Z",
"link": "https://modrinth.com/news/article/joining-spark-universe"
},
{
"title": "Manage servers together",
"summary": "Add other users to your server, assign roles, and track whats changed.",
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT v.mod_id dependent_project_id, d.mod_dependency_id dependency_project_id,\n m.name dependency_name, m.slug dependency_slug, m.icon_url dependency_icon_url\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n INNER JOIN mods m ON m.id = d.mod_dependency_id\n WHERE v.mod_id = ANY($1)\n AND d.mod_dependency_id IS NOT NULL\n AND m.status = ANY($2)\n ",
"query": "\n SELECT DISTINCT v.mod_id dependent_project_id,\n d.mod_dependency_id dependency_project_id,\n d.dependency_type dependency_type,\n m.name dependency_name,\n m.slug dependency_slug,\n m.icon_url dependency_icon_url\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n INNER JOIN mods m ON m.id = d.mod_dependency_id\n WHERE v.mod_id = ANY($1)\n AND d.mod_dependency_id IS NOT NULL\n AND m.status = ANY($2)\n ",
"describe": {
"columns": [
{
@@ -15,16 +15,21 @@
},
{
"ordinal": 2,
"name": "dependency_name",
"name": "dependency_type",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "dependency_slug",
"name": "dependency_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "dependency_slug",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "dependency_icon_url",
"type_info": "Varchar"
}
@@ -39,9 +44,10 @@
false,
true,
false,
false,
true,
true
]
},
"hash": "20cb8a3d45911a126aa17451b1e6e84e9238dee422cc2b400cea0048a71e4c27"
"hash": "3afdd6b9070ea0951682facf207b9056ac842c402bd0941c45ebd1dc6d627d43"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, username, avatar_url\n FROM users\n WHERE LOWER(username) LIKE $1 ESCAPE ''\n ORDER BY LOWER(username) = $2 DESC, LOWER(username), username\n LIMIT 25\n ",
"query": "\n SELECT id, username, avatar_url\n FROM users\n WHERE LOWER(username) LIKE $1 ESCAPE '\\'\n ORDER BY LOWER(username) = $2 DESC, LOWER(username), username\n LIMIT 25\n ",
"describe": {
"columns": [
{
@@ -31,5 +31,5 @@
true
]
},
"hash": "8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531"
"hash": "d0cabd1c74fa04c77a02e99e201e3f3c54b41e9f606db1f18accee33afdddf49"
}
@@ -286,13 +286,13 @@ impl DBUser {
let escaped_query = format!("{}%", escape_like(&lowercase_query));
let users = sqlx::query!(
"
r#"
SELECT id, username, avatar_url
FROM users
WHERE LOWER(username) LIKE $1 ESCAPE '\'
ORDER BY LOWER(username) = $2 DESC, LOWER(username), username
LIMIT 25
",
"#,
escaped_query,
lowercase_query
)
@@ -485,6 +485,13 @@ impl SearchField {
sort: false,
optional: true,
},
SearchField::CompatibleDependencyProjectIds => TypesenseFieldSpec {
path: "compatible_dependency_project_ids",
ty: "string[]",
facet: true,
sort: false,
optional: true,
},
}
}
}
+51 -30
View File
@@ -21,7 +21,7 @@ use crate::database::models::{
use crate::database::redis::RedisPool;
use crate::models::exp;
use crate::models::ids::ProjectId;
use crate::models::projects::from_duplicate_version_fields;
use crate::models::projects::{DependencyType, from_duplicate_version_fields};
use crate::models::v2::projects::LegacyProject;
use crate::routes::v2_reroute;
use crate::search::{SearchProjectDependency, UploadSearchProject};
@@ -124,10 +124,14 @@ pub async fn index_local(
info!("Indexing local dependencies!");
let dependencies: DashMap<DBProjectId, Vec<SearchProjectDependency>> =
sqlx::query!(
"
SELECT DISTINCT v.mod_id dependent_project_id, d.mod_dependency_id dependency_project_id,
m.name dependency_name, m.slug dependency_slug, m.icon_url dependency_icon_url
sqlx::query!(
"
SELECT DISTINCT v.mod_id dependent_project_id,
d.mod_dependency_id dependency_project_id,
d.dependency_type dependency_type,
m.name dependency_name,
m.slug dependency_slug,
m.icon_url dependency_icon_url
FROM versions v
INNER JOIN dependencies d ON d.dependent_id = v.id
INNER JOIN mods m ON m.id = d.mod_dependency_id
@@ -135,32 +139,35 @@ pub async fn index_local(
AND d.mod_dependency_id IS NOT NULL
AND m.status = ANY($2)
",
&project_ids,
&searchable_statuses,
)
.fetch(pool)
.try_fold(
DashMap::new(),
|acc: DashMap<DBProjectId, Vec<SearchProjectDependency>>, m| {
if let Some(dependency_project_id) = m.dependency_project_id {
acc.entry(DBProjectId(m.dependent_project_id))
.or_default()
.push(SearchProjectDependency {
project_id: ProjectId::from(DBProjectId(
dependency_project_id,
))
.to_string(),
name: m.dependency_name,
slug: m.dependency_slug,
icon_url: m.dependency_icon_url,
});
}
&project_ids,
&searchable_statuses,
)
.fetch(pool)
.try_fold(
DashMap::new(),
|acc: DashMap<DBProjectId, Vec<SearchProjectDependency>>, m| {
if let Some(dependency_project_id) = m.dependency_project_id {
acc.entry(DBProjectId(m.dependent_project_id))
.or_default()
.push(SearchProjectDependency {
project_id: ProjectId::from(DBProjectId(
dependency_project_id,
))
.to_string(),
dependency_type: DependencyType::from_string(
&m.dependency_type,
),
name: m.dependency_name,
slug: m.dependency_slug,
icon_url: m.dependency_icon_url,
});
}
async move { Ok(acc) }
},
)
.await
.wrap_err("failed to fetch project dependencies")?;
async move { Ok(acc) }
},
)
.await
.wrap_err("failed to fetch project dependencies")?;
struct PartialGallery {
url: String,
@@ -398,6 +405,18 @@ pub async fn index_local(
.iter()
.map(|dependency| dependency.project_id.clone())
.collect::<Vec<_>>();
let compatible_dependency_project_ids = dependencies
.iter()
.filter(|dependency| {
matches!(
dependency.dependency_type,
DependencyType::Required
| DependencyType::Optional
| DependencyType::Embedded
)
})
.map(|dependency| dependency.project_id.clone())
.collect::<Vec<_>>();
if let Some(versions) = versions.remove(&project.id) {
// Aggregated project loader fields
@@ -539,6 +558,8 @@ pub async fn index_local(
open_source,
color: project.color.map(|x| x as u32),
dependency_project_ids: dependency_project_ids.clone(),
compatible_dependency_project_ids:
compatible_dependency_project_ids.clone(),
dependencies: dependencies.clone(),
loader_fields,
project_loader_fields: project_loader_fields.clone(),
+9
View File
@@ -2,6 +2,7 @@ use crate::database::redis::RedisPool;
use crate::models::exp;
use crate::models::exp::minecraft::JavaServerPing;
use crate::models::ids::{ProjectId, VersionId};
use crate::models::projects::DependencyType;
use crate::queue::server_ping;
use crate::routes::ApiError;
use crate::{database::PgPool, env::ENV};
@@ -196,6 +197,7 @@ pub enum SearchField {
MinecraftJavaServerContentSupportedGameVersions,
MinecraftJavaServerPingData,
DependencyProjectIds,
CompatibleDependencyProjectIds,
}
#[derive(Debug, Error)]
@@ -252,6 +254,8 @@ pub struct UploadSearchProject {
#[serde(default)]
pub dependency_project_ids: Vec<String>,
#[serde(default)]
pub compatible_dependency_project_ids: Vec<String>,
#[serde(default)]
pub dependencies: Vec<SearchProjectDependency>,
// Hidden fields to get the Project model out of the search results.
@@ -267,6 +271,7 @@ pub struct UploadSearchProject {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SearchProjectDependency {
pub project_id: String,
pub dependency_type: DependencyType,
pub name: String,
pub slug: Option<String>,
pub icon_url: Option<String>,
@@ -311,6 +316,8 @@ pub struct ResultSearchProject {
#[serde(default)]
pub dependency_project_ids: Vec<String>,
#[serde(default)]
pub compatible_dependency_project_ids: Vec<String>,
#[serde(default)]
pub dependencies: Vec<SearchProjectDependency>,
// Hidden fields to get the Project model out of the search results.
@@ -350,6 +357,8 @@ impl From<UploadSearchProject> for ResultSearchProject {
featured_gallery: source.featured_gallery,
color: source.color,
dependency_project_ids: source.dependency_project_ids,
compatible_dependency_project_ids: source
.compatible_dependency_project_ids,
dependencies: source.dependencies,
loaders: source.loaders,
project_loader_fields: source.project_loader_fields,
+19
View File
@@ -9,6 +9,7 @@ use common::environment::TestEnvironment;
use common::environment::with_test_environment;
use common::search::setup_search_projects;
use futures::stream::StreamExt;
use labrinth::models::projects::DependencyType;
use serde_json::json;
use crate::common::api_common::Api;
@@ -95,6 +96,12 @@ async fn search_projects() {
)]]),
vec![7],
),
(
json!([[format!(
"compatible_dependency_project_ids:{dependency_project_id}"
)]]),
vec![7],
),
];
// TODO: versions, game versions
// Untested:
@@ -151,11 +158,23 @@ async fn search_projects() {
projects.hits[0].dependency_project_ids[0],
dependency_project_id
);
assert_eq!(
projects.hits[0].compatible_dependency_project_ids.len(),
1
);
assert_eq!(
projects.hits[0].compatible_dependency_project_ids[0],
dependency_project_id
);
assert_eq!(projects.hits[0].dependencies.len(), 1);
assert_eq!(
projects.hits[0].dependencies[0].project_id,
dependency_project_id
);
assert_eq!(
projects.hits[0].dependencies[0].dependency_type,
DependencyType::Required
);
assert!(
projects.hits[0].dependencies[0]
.slug
+21
View File
@@ -79,6 +79,16 @@ pub async fn search_users_escapes_wildcards_and_limits_results() {
.unwrap();
}
sqlx::query(
"
INSERT INTO users (id, username, email, role)
VALUES (2100, 'prefix_under_score', 'prefix_under_score@modrinth.com', 'developer')
",
)
.execute(&*test_env.db.pool)
.await
.unwrap();
let req = test::TestRequest::get()
.uri("/v3/users/search?query=prefix")
.to_request();
@@ -104,6 +114,17 @@ pub async fn search_users_escapes_wildcards_and_limits_results() {
test::read_body_json(resp).await;
assert!(users.is_empty());
let req = test::TestRequest::get()
.uri("/v3/users/search?query=prefix_")
.to_request();
let resp = test_env.call(req).await;
assert_status!(&resp, actix_http::StatusCode::OK);
let users: Vec<serde_json::Value> =
test::read_body_json(resp).await;
assert_eq!(users.len(), 1);
assert_eq!(users[0]["username"], "prefix_under_score");
let req = test::TestRequest::get()
.uri("/v3/users/search?query=%20%20")
.to_request();