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
+1 -6
View File
@@ -1,12 +1,7 @@
{
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": false,
"editor.insertSpaces": false,
"files.eol": "\n",
@@ -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();
+2
View File
@@ -78,6 +78,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
),
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
).await?;
@@ -92,6 +93,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
None,
None,
Some((&loading_bar, 80.0)),
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -82,6 +82,7 @@ pub async fn import_curseforge(
&thumbnail_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -326,6 +326,7 @@ pub async fn generate_pack_from_version_id(
None,
Some(&download_meta),
Some((&loading_bar, 70.0)),
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -356,6 +357,7 @@ pub async fn generate_pack_from_version_id(
&icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -441,6 +441,7 @@ pub async fn install_zipped_mrpack_files(
.collect::<Vec<&str>>(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
Some(&download_meta),
None,
&state.fetch_semaphore,
&state.pool,
)
@@ -456,6 +457,7 @@ pub async fn install_zipped_mrpack_files(
project.path.as_str(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
None,
&state.pool,
)
.await?;
@@ -514,6 +516,7 @@ pub async fn install_zipped_mrpack_files(
ProjectType::get_from_parent_folder(
relative_override_file_path.as_str(),
),
None,
&state.pool,
)
.await?;
@@ -113,6 +113,7 @@ pub async fn profile_create(
icon,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
+37 -6
View File
@@ -547,6 +547,7 @@ pub async fn add_project_from_path(
bytes::Bytes::from(file),
None,
project_type,
None,
&state.io_semaphore,
&state.pool,
)
@@ -666,7 +667,7 @@ pub async fn export_mrpack(
}
// File is not in the config file, add it to the .mrpack zip
if path.is_file() {
if path.is_file() && is_path_exportable(&relative_path) {
let mut file = File::open(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
@@ -695,6 +696,30 @@ pub async fn export_mrpack(
Ok(())
}
fn is_path_exportable(relative_path: &SafeRelativeUtf8UnixPathBuf) -> bool {
if relative_path.ends_with(".DS_Store") {
return false;
}
if relative_path.starts_with("mods/.connector/")
|| relative_path.starts_with(".sable/natives/")
|| relative_path.starts_with("local/crash_assistant/")
|| relative_path.starts_with("mods/mcef-libraries/")
|| relative_path.starts_with("mods/mcef-cache/")
|| relative_path.starts_with("config/super_resolution/libraries/")
|| relative_path.starts_with("config/Veinminer/update/")
|| relative_path.starts_with("config/epicfight/native/")
|| relative_path.starts_with("essential/")
|| relative_path.starts_with(".mixin.out/")
|| relative_path.starts_with(".fabric/")
|| relative_path.starts_with("__MACOSX/")
{
return false;
}
true
}
// Given a folder path, populate a Vec of all the subfolders and files, at most 2 layers deep
// profile
// -- folder1
@@ -726,14 +751,20 @@ pub async fn get_pack_export_candidates(
.await
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
{
path_list.push(pack_get_relative_path(
&profile_base_dir,
&entry.path(),
)?);
let relative =
pack_get_relative_path(&profile_base_dir, &entry.path())?;
if !is_path_exportable(&relative) {
continue;
}
path_list.push(relative);
}
} else {
// One layer of files/folders if its a file
path_list.push(pack_get_relative_path(&profile_base_dir, &path)?);
let relative = pack_get_relative_path(&profile_base_dir, &path)?;
if !is_path_exportable(&relative) {
continue;
}
path_list.push(relative);
}
}
Ok(path_list)
+2 -2
View File
@@ -68,8 +68,8 @@ pub enum ErrorKind {
#[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Too many API errors; temporarily blocked")]
ApiIsDownError,
#[error("Too many API errors, try again in {0} minutes")]
ApiIsDownError(u32),
#[error("{0}")]
LabrinthError(LabrinthError),
+18 -4
View File
@@ -88,6 +88,7 @@ pub async fn download_version_info(
&version.url,
None,
None,
None,
&st.api_semaphore,
&st.pool,
)
@@ -99,6 +100,7 @@ pub async fn download_version_info(
&loader.url,
None,
None,
None,
&st.api_semaphore,
&st.pool,
)
@@ -149,6 +151,7 @@ pub async fn download_client(
&client_download.url,
Some(&client_download.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -189,6 +192,7 @@ pub async fn download_assets_index(
&version.asset_index.url,
None,
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -239,7 +243,7 @@ pub async fn download_assets(
async {
if !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
.get_or_try_init(|| fetch(&url, Some(hash), None, None, &st.fetch_semaphore, &st.pool))
.await?;
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched asset with hash {hash}");
@@ -253,7 +257,7 @@ pub async fn download_assets(
if with_legacy && !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
.get_or_try_init(|| fetch(&url, Some(hash), None, None, &st.fetch_semaphore, &st.pool))
.await?;
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched legacy asset with hash {hash}");
@@ -328,6 +332,7 @@ pub async fn download_libraries(
&native.url,
Some(&native.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -373,6 +378,7 @@ pub async fn download_libraries(
&artifact.url,
Some(&artifact.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -409,8 +415,15 @@ pub async fn download_libraries(
// failed download here is not a fatal condition.
//
// See DEV-479.
match fetch(&url, None, None, &st.fetch_semaphore, &st.pool)
.await
match fetch(
&url,
None,
None,
None,
&st.fetch_semaphore,
&st.pool,
)
.await
{
Ok(bytes) => {
write(&path, &bytes, &st.io_semaphore).await?;
@@ -470,6 +483,7 @@ pub async fn download_log_config(
&log_download.url,
Some(&log_download.sha1),
None,
None,
&st.fetch_semaphore,
&st.pool,
)
+78 -2
View File
@@ -372,6 +372,16 @@ pub struct CachedFileHash {
pub size: u64,
pub hash: String,
pub project_type: Option<ProjectType>,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub version_id: Option<String>,
}
#[derive(Clone, Copy, Debug)]
pub struct KnownModrinthFile<'a> {
pub project_id: &'a str,
pub version_id: &'a str,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -1080,6 +1090,7 @@ impl CachedEntry {
method: Method,
api_url: &str,
url: &str,
uri_path: Option<&'static str>,
keys: &DashSet<impl Display + Eq + Hash + Serialize>,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
@@ -1102,6 +1113,7 @@ impl CachedEntry {
url,
None,
None,
uri_path,
fetch_semaphore,
pool,
)
@@ -1112,11 +1124,12 @@ impl CachedEntry {
}
macro_rules! fetch_original_values {
($type:ident, $api_url:expr, $url_suffix:expr, $cache_variant:path) => {{
($type:ident, $api_url:expr, $url_suffix:expr, $uri_path:expr, $cache_variant:path) => {{
let mut results = fetch_many_batched(
Method::GET,
$api_url,
&format!("{}?ids=", $url_suffix),
$uri_path,
&keys,
&fetch_semaphore,
&pool,
@@ -1172,7 +1185,7 @@ impl CachedEntry {
}
macro_rules! fetch_original_value {
($type:ident, $api_url:expr, $url_suffix:expr, $cache_variant:path) => {{
($type:ident, $api_url:expr, $url_suffix:expr, $uri_path:expr, $cache_variant:path) => {{
vec![(
$cache_variant(
fetch_json(
@@ -1180,6 +1193,7 @@ impl CachedEntry {
&*format!("{}{}", $api_url, $url_suffix),
None,
None,
$uri_path,
&fetch_semaphore,
pool,
)
@@ -1197,6 +1211,7 @@ impl CachedEntry {
Project,
env!("MODRINTH_API_URL"),
"projects",
Some("/v2/projects"),
CacheValue::Project
)
}
@@ -1205,6 +1220,7 @@ impl CachedEntry {
ProjectV3,
env!("MODRINTH_API_URL_V3"),
"projects",
Some("/v3/projects"),
CacheValue::ProjectV3
)
}
@@ -1213,6 +1229,7 @@ impl CachedEntry {
Version,
env!("MODRINTH_API_URL"),
"versions",
Some("/v2/versions"),
CacheValue::Version
)
}
@@ -1221,6 +1238,7 @@ impl CachedEntry {
User,
env!("MODRINTH_API_URL"),
"users",
Some("/v2/users"),
CacheValue::User
)
}
@@ -1229,6 +1247,7 @@ impl CachedEntry {
Method::GET,
env!("MODRINTH_API_URL_V3"),
"teams?ids=",
Some("/v3/teams"),
&keys,
fetch_semaphore,
pool,
@@ -1268,6 +1287,7 @@ impl CachedEntry {
Method::GET,
env!("MODRINTH_API_URL_V3"),
"organizations?ids=",
Some("/v3/organizations"),
&keys,
fetch_semaphore,
pool,
@@ -1327,6 +1347,7 @@ impl CachedEntry {
"algorithm": "sha1",
"hashes": &keys,
})),
Some("/v2/version_files"),
fetch_semaphore,
pool,
)
@@ -1393,6 +1414,7 @@ impl CachedEntry {
url,
None,
None,
None,
fetch_semaphore,
pool,
)
@@ -1421,6 +1443,7 @@ impl CachedEntry {
"minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION
),
None,
CacheValue::MinecraftManifest
)
}
@@ -1429,6 +1452,7 @@ impl CachedEntry {
Categories,
env!("MODRINTH_API_URL"),
"tag/category",
Some("/v2/tag/category"),
CacheValue::Categories
)
}
@@ -1437,6 +1461,7 @@ impl CachedEntry {
ReportTypes,
env!("MODRINTH_API_URL"),
"tag/report_type",
Some("/v2/tag/report_type"),
CacheValue::ReportTypes
)
}
@@ -1445,6 +1470,7 @@ impl CachedEntry {
Loaders,
env!("MODRINTH_API_URL"),
"tag/loader",
Some("/v2/tag/loader"),
CacheValue::Loaders
)
}
@@ -1453,6 +1479,7 @@ impl CachedEntry {
GameVersions,
env!("MODRINTH_API_URL"),
"tag/game_version",
Some("/v2/tag/game_version"),
CacheValue::GameVersions
)
}
@@ -1461,6 +1488,7 @@ impl CachedEntry {
DonationPlatforms,
env!("MODRINTH_API_URL"),
"tag/donation_platform",
Some("/v2/tag/donation_platform"),
CacheValue::DonationPlatforms
)
}
@@ -1503,6 +1531,8 @@ impl CachedEntry {
project_type: ProjectType::get_from_parent_folder(
&full_path,
),
project_id: None,
version_id: None,
})
.get_entry(),
true,
@@ -1617,6 +1647,7 @@ impl CachedEntry {
"game_versions": [game_version],
"version_types": version_types
})),
Some("/v2/version_files/update_many"),
fetch_semaphore,
pool,
)
@@ -1653,13 +1684,32 @@ impl CachedEntry {
let versions = variation.remove(hash);
if let Some(versions) = versions {
let mut emitted_update = false;
for version in versions {
let version_id = version.id.clone();
let target_hash = version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first())
.and_then(|file| file.hashes.get("sha1"))
.map(String::as_str);
// Some update responses point at a different version ID for the exact installed file.
let same_file =
target_hash == Some(hash.as_str());
vals.push((
CacheValue::Version(version).get_entry(),
false,
));
if same_file {
continue;
}
emitted_update = true;
vals.push((
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
@@ -1676,6 +1726,16 @@ impl CachedEntry {
true,
));
}
if !emitted_update {
vals.push((
CacheValueType::FileUpdate
.get_empty_entry(format!(
"{hash}-{loaders_key}-{channel_policy_key}-{game_version}"
)),
true,
));
}
} else {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(
@@ -1713,6 +1773,7 @@ impl CachedEntry {
url,
None,
None,
Some("/v2/search"),
fetch_semaphore,
pool,
)
@@ -1754,6 +1815,7 @@ impl CachedEntry {
&url,
None,
None,
Some("/v2/project/:id/version"),
fetch_semaphore,
pool,
)
@@ -1805,6 +1867,7 @@ impl CachedEntry {
url,
None,
None,
Some("/v3/search"),
fetch_semaphore,
pool,
)
@@ -2061,6 +2124,7 @@ pub async fn cache_file_hash(
path: &str,
known_hash: Option<&str>,
project_type: Option<ProjectType>,
known_modrinth_file: Option<KnownModrinthFile<'_>>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let size = bytes.len();
@@ -2077,6 +2141,7 @@ pub async fn cache_file_hash(
size as u64,
hash,
project_type,
known_modrinth_file,
exec,
)
.await
@@ -2088,8 +2153,17 @@ pub async fn cache_file_hash_metadata(
size: u64,
hash: String,
project_type: Option<ProjectType>,
known_modrinth_file: Option<KnownModrinthFile<'_>>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let (project_id, version_id) =
known_modrinth_file.map_or((None, None), |metadata| {
(
Some(metadata.project_id.to_string()),
Some(metadata.version_id.to_string()),
)
});
// Streamed extraction already computed these values, so avoid buffering the file just to cache them.
CachedEntry::upsert_many(
&[CacheValue::FileHash(CachedFileHash {
@@ -2097,6 +2171,8 @@ pub async fn cache_file_hash_metadata(
size,
hash,
project_type,
project_id,
version_id,
})
.get_entry()],
exec,
+3
View File
@@ -331,6 +331,7 @@ impl FriendsSocket {
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
None,
None,
Some("/v3/friends"),
semaphore,
exec,
)
@@ -359,6 +360,7 @@ impl FriendsSocket {
None,
None,
None,
Some("/v3/friend/:user_id"),
semaphore,
exec,
)
@@ -392,6 +394,7 @@ impl FriendsSocket {
None,
None,
None,
Some("/v3/friend/:user_id"),
semaphore,
exec,
)
@@ -895,6 +895,7 @@ async fn get_modpack_identifiers(
&[&primary_file.url],
primary_file.hashes.get("sha1").map(|s| s.as_str()),
Some(&download_meta),
None,
fetch_semaphore,
pool,
)
@@ -226,6 +226,10 @@ where
ProjectType::get_from_parent_folder(
&full_path,
),
project_id: Some(
version.project_id.clone(),
),
version_id: Some(version.id.clone()),
},
));
}
+2
View File
@@ -36,6 +36,7 @@ impl ModrinthCredentials {
Some(("Authorization", &*creds.session)),
None,
None,
Some("/v2/session/refresh"),
semaphore,
exec,
)
@@ -228,6 +229,7 @@ async fn fetch_info(
Some(("Authorization", token)),
None,
None,
Some("/v2/user"),
semaphore,
exec,
)
+215 -2
View File
@@ -2,8 +2,8 @@ use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::profile::get_full_path;
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, ReleaseChannel,
cache_file_hash,
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, KnownModrinthFile,
ReleaseChannel, Version, cache_file_hash,
};
use crate::util;
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
@@ -1022,6 +1022,16 @@ impl Profile {
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_info_by_hash = Self::resolve_installed_file_metadata(
&file_hashes,
&keys,
file_info_by_hash,
self,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let file_hashes = file_hashes
.into_iter()
@@ -1080,6 +1090,16 @@ impl Profile {
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_info_by_hash = Self::resolve_installed_file_metadata(
&file_hashes,
&keys,
file_info_by_hash,
self,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let installed_channels = Self::get_installed_update_channels(
&file_info_by_hash,
@@ -1331,6 +1351,192 @@ impl Profile {
)
}
async fn resolve_installed_file_metadata(
file_hashes: &[CachedFileHash],
scan_files: &[InitialScanFile],
mut file_info_by_hash: HashMap<String, CachedFile>,
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<HashMap<String, CachedFile>> {
let scan_files_by_path: HashMap<&str, &InitialScanFile> = scan_files
.iter()
.map(|file| (file.path.as_str(), file))
.collect();
struct MetadataCandidate {
hash: String,
project_id: String,
version_id: String,
file_name: String,
project_type: ProjectType,
}
let mut candidates = Vec::new();
let mut version_ids = HashSet::new();
for file_hash in file_hashes {
if let (Some(project_id), Some(version_id)) =
(&file_hash.project_id, &file_hash.version_id)
{
file_info_by_hash.insert(
file_hash.hash.clone(),
CachedFile {
hash: file_hash.hash.clone(),
project_id: project_id.clone(),
version_id: version_id.clone(),
},
);
continue;
}
let Some(file_info) = file_info_by_hash.get(&file_hash.hash) else {
continue;
};
let Some(scan_file) = scan_files_by_path
.get(file_hash.path.trim_end_matches(".disabled"))
else {
continue;
};
version_ids.insert(file_info.version_id.clone());
candidates.push(MetadataCandidate {
hash: file_hash.hash.clone(),
project_id: file_info.project_id.clone(),
version_id: file_info.version_id.clone(),
file_name: scan_file.file_name.clone(),
project_type: scan_file.project_type,
});
}
let version_ids_ref =
version_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>();
let versions_by_id: HashMap<String, Version> =
CachedEntry::get_version_many(
&version_ids_ref,
None,
pool,
fetch_semaphore,
)
.await?
.into_iter()
.map(|version| (version.id.clone(), version))
.collect();
let mut project_versions_by_id: HashMap<String, Option<Vec<Version>>> =
HashMap::new();
for candidate in candidates {
if versions_by_id.get(&candidate.version_id).is_some_and(
|version| {
Self::version_matches_file(
version,
&candidate.hash,
&candidate.file_name,
candidate.project_type,
profile,
)
},
) {
continue;
}
if !project_versions_by_id.contains_key(&candidate.project_id) {
let versions = CachedEntry::get_project_versions(
&candidate.project_id,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
project_versions_by_id
.insert(candidate.project_id.clone(), versions);
}
let Some(Some(versions)) =
project_versions_by_id.get(&candidate.project_id)
else {
continue;
};
if let Some(version) = Self::find_matching_file_version(
versions,
&candidate.hash,
&candidate.file_name,
candidate.project_type,
profile,
) {
file_info_by_hash.insert(
candidate.hash.clone(),
CachedFile {
hash: candidate.hash,
project_id: version.project_id.clone(),
version_id: version.id.clone(),
},
);
}
}
Ok(file_info_by_hash)
}
fn find_matching_file_version<'a>(
versions: &'a [Version],
hash: &str,
file_name: &str,
project_type: ProjectType,
profile: &Profile,
) -> Option<&'a Version> {
versions.iter().find(|version| {
Self::version_matches_file(
version,
hash,
file_name,
project_type,
profile,
)
})
}
fn version_matches_file(
version: &Version,
hash: &str,
file_name: &str,
project_type: ProjectType,
profile: &Profile,
) -> bool {
version.game_versions.contains(&profile.game_version)
&& Self::version_loaders_match_profile(
version,
project_type,
profile,
)
&& version.files.iter().any(|file| {
file.hashes.get("sha1").is_some_and(|sha1| sha1 == hash)
&& (file.primary
|| file.filename
== file_name.trim_end_matches(".disabled"))
})
}
fn version_loaders_match_profile(
version: &Version,
project_type: ProjectType,
profile: &Profile,
) -> bool {
if project_type == ProjectType::Mod {
version
.loaders
.iter()
.any(|loader| loader == profile.loader.as_str())
} else {
version.loaders.iter().any(|loader| {
project_type.get_loaders().contains(&loader.as_str())
})
}
}
#[tracing::instrument(skip(pool))]
pub async fn add_project_version(
profile_path: &str,
@@ -1382,6 +1588,7 @@ impl Profile {
&file.url,
file.hashes.get("sha1").map(|x| &**x),
Some(&download_meta),
None,
fetch_semaphore,
pool,
)
@@ -1393,6 +1600,10 @@ impl Profile {
bytes,
file.hashes.get("sha1").map(|x| &**x),
ProjectType::get_from_loaders(version.loaders.clone()),
Some(KnownModrinthFile {
project_id: &version.project_id,
version_id: &version.id,
}),
io_semaphore,
pool,
)
@@ -1408,6 +1619,7 @@ impl Profile {
bytes: bytes::Bytes,
hash: Option<&str>,
project_type: Option<ProjectType>,
known_modrinth_file: Option<KnownModrinthFile<'_>>,
io_semaphore: &IoSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<String> {
@@ -1455,6 +1667,7 @@ impl Profile {
&project_path,
hash,
Some(project_type),
known_modrinth_file,
exec,
)
.await?;
+97 -16
View File
@@ -10,7 +10,7 @@ use rand::Rng;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
@@ -50,20 +50,48 @@ pub struct IoSemaphore(pub Semaphore);
pub struct FetchSemaphore(pub Semaphore);
struct FetchFence {
inner: Mutex<FenceInner>,
inner: Mutex<HashMap<&'static str, FenceInner>>,
}
impl FetchFence {
pub fn is_blocked(&self) -> bool {
self.inner.lock().is_blocked()
pub fn is_blocked(&self, key: &'static str) -> bool {
self.inner
.lock()
.entry(key)
.or_insert_with(FenceInner::new)
.is_blocked()
}
pub fn record_ok(&self) {
self.inner.lock().record_ok()
pub fn record_ok(&self, key: &'static str) {
self.inner
.lock()
.entry(key)
.or_insert_with(FenceInner::new)
.record_ok()
}
pub fn record_fail(&self) {
self.inner.lock().record_fail()
pub fn record_fail(&self, key: &'static str) {
self.inner
.lock()
.entry(key)
.or_insert_with(FenceInner::new)
.record_fail()
}
pub fn latest_block_minutes(&self) -> u32 {
let now = Utc::now();
self.inner
.lock()
.values()
.filter_map(|fence| fence.block_until)
.filter(|until| *until > now)
.max()
.map(|until| {
let seconds = until.signed_duration_since(now).num_seconds();
(seconds.max(0) as u32).div_ceil(60).max(1)
})
.unwrap_or(1)
}
}
@@ -154,7 +182,7 @@ impl FenceInner {
static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
LazyLock::new(|| FetchFence {
inner: Mutex::new(FenceInner::new()),
inner: Mutex::new(HashMap::new()),
});
fn reqwest_client_builder() -> reqwest::ClientBuilder {
@@ -184,6 +212,7 @@ pub async fn fetch(
url: &str,
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
@@ -195,6 +224,7 @@ pub async fn fetch(
None,
download_meta,
None,
uri_path,
semaphore,
exec,
)
@@ -206,6 +236,7 @@ pub async fn fetch_with_client(
url: &str,
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
client: &reqwest::Client,
@@ -218,6 +249,7 @@ pub async fn fetch_with_client(
None,
download_meta,
None,
uri_path,
semaphore,
exec,
client,
@@ -231,6 +263,7 @@ pub async fn fetch_json<T>(
url: &str,
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<T>
@@ -238,7 +271,8 @@ where
T: DeserializeOwned,
{
let result = fetch_advanced(
method, url, sha1, json_body, None, None, None, semaphore, exec,
method, url, sha1, json_body, None, None, None, uri_path, semaphore,
exec,
)
.await?;
let value = serde_json::from_slice(&result)?;
@@ -257,6 +291,7 @@ pub async fn fetch_advanced(
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
@@ -268,6 +303,7 @@ pub async fn fetch_advanced(
header,
download_meta,
loading_bar,
uri_path,
semaphore,
exec,
&INSECURE_REQWEST_CLIENT,
@@ -286,6 +322,7 @@ pub async fn fetch_advanced_with_client(
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
client: &reqwest::Client,
@@ -294,6 +331,7 @@ pub async fn fetch_advanced_with_client(
let is_api_url = url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3"));
let fence_key = if is_api_url { uri_path } else { None };
let creds = if header
.as_ref()
@@ -309,8 +347,13 @@ pub async fn fetch_advanced_with_client(
.map(|m| (DOWNLOAD_META_HEADER.to_string(), m.to_header_value()));
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if is_api_url && GLOBAL_FETCH_FENCE.is_blocked() {
return Err(ErrorKind::ApiIsDownError.into());
if let Some(fence_key) = fence_key
&& GLOBAL_FETCH_FENCE.is_blocked(fence_key)
{
return Err(ErrorKind::ApiIsDownError(
GLOBAL_FETCH_FENCE.latest_block_minutes(),
)
.into());
}
let mut req = client.request(method.clone(), url);
@@ -336,8 +379,8 @@ pub async fn fetch_advanced_with_client(
match result {
Ok(resp) => {
if resp.status().is_server_error() {
if is_api_url {
GLOBAL_FETCH_FENCE.record_fail();
if let Some(fence_key) = fence_key {
GLOBAL_FETCH_FENCE.record_fail(fence_key);
}
if attempt <= FETCH_ATTEMPTS {
@@ -400,8 +443,8 @@ pub async fn fetch_advanced_with_client(
tracing::trace!("Done downloading URL {url}");
if is_api_url {
GLOBAL_FETCH_FENCE.record_ok();
if let Some(fence_key) = fence_key {
GLOBAL_FETCH_FENCE.record_ok(fence_key);
}
return Ok(bytes);
@@ -427,6 +470,7 @@ pub async fn fetch_mirrors(
mirrors: &[&str],
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Bytes> {
@@ -441,6 +485,7 @@ pub async fn fetch_mirrors(
mirror,
sha1,
download_meta,
uri_path,
semaphore,
exec,
&REQWEST_CLIENT,
@@ -620,6 +665,42 @@ mod tests {
assert!(fence.is_blocked());
}
#[test]
fn test_fetch_fence_keys_are_independent() {
let fence = FetchFence {
inner: Mutex::new(HashMap::new()),
};
for _ in 0..FenceInner::FAILURE_THRESHOLD {
fence.record_fail("/v3/version_file/:sha1/update");
}
assert!(fence.is_blocked("/v3/version_file/:sha1/update"));
assert!(!fence.is_blocked("/v3/project/:id"));
}
#[test]
fn test_fetch_fence_latest_block_minutes() {
let fence = FetchFence {
inner: Mutex::new(HashMap::new()),
};
{
let mut inner = fence.inner.lock();
inner.insert("/expired", FenceInner::new());
inner.get_mut("/expired").unwrap().block_until =
Some(Utc::now() - TimeDelta::minutes(1));
inner.insert("/short", FenceInner::new());
inner.get_mut("/short").unwrap().block_until =
Some(Utc::now() + TimeDelta::seconds(61));
inner.insert("/long", FenceInner::new());
inner.get_mut("/long").unwrap().block_until =
Some(Utc::now() + TimeDelta::seconds(140));
}
assert_eq!(fence.latest_block_minutes(), 3);
}
#[test]
fn test_fence_block_after_4_fails_with_oks() {
// Update tests if the FenceInner constants change
+2
View File
@@ -417,6 +417,7 @@ import _UserSearchIcon from './icons/user-search.svg?component'
import _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component'
import _VersionIcon from './icons/version.svg?component'
import _VideoIcon from './icons/video.svg?component'
import _WikiIcon from './icons/wiki.svg?component'
import _WindowIcon from './icons/window.svg?component'
import _WorldIcon from './icons/world.svg?component'
@@ -839,6 +840,7 @@ export const UserSearchIcon = _UserSearchIcon
export const UserXIcon = _UserXIcon
export const UsersIcon = _UsersIcon
export const VersionIcon = _VersionIcon
export const VideoIcon = _VideoIcon
export const WikiIcon = _WikiIcon
export const WindowIcon = _WindowIcon
export const WorldIcon = _WorldIcon
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-video"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
<rect x="2" y="6" width="14" height="12" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 427 B

@@ -0,0 +1,100 @@
---
title: Modrinth joins Spark Universe
summary: The next chapter. What it means and why we think its right for Modrinth.
date: 2026-06-15T07:00:00-07:00
authors: ['MpxzqsyW']
---
### Hey everyone, Jai here!
I know this news comes as a surprise, so I wanted to talk through why we think its right for Modrinth and what it means for us.
<div id="spark-live-widget"></div>
### My journey with Modrinth
I made my first Minecraft mod when I was nine. It was a little tech mod called [Mine-Tech](https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/minecraft-mods/2643681-mine-tech#c1) that I uploaded to the Minecraft Forums. I got into modding because I watched PopularMMOs after school every day and wanted to make something cool enough to show up in one of his videos. That never happened, but it's what got me coding.
I spent most of my teenage years bouncing around Minecraft: working on servers, joining modding teams, making a dinosaur mod called [Prehistoric Eclipse](https://www.curseforge.com/minecraft/mc-mods/prehistoric-eclipse) that somehow got hundreds of thousands of downloads. Minecraft modding is genuinely how I learned to program.
In 2020, COVID hit and I found myself with a lot of time on my hands. The platforms were still bad. Search was broken, creators were an afterthought, and nothing had really improved in years. So I started building a search engine that indexed mods from other platforms. They quickly shut us down for scraping (fair enough), and at that point I asked myself, “why not just build the whole platform from scratch?”
I open-sourced it, and people actually showed up to help build it with me. That's how Modrinth really started, as a thing I was building that other people wanted to exist too.
Over the next year and a half, I kept working on it, mostly during school, sometimes instead of school. Listening to creators, shipping features, fixing things that broke. By 2022 we had a million monthly visitors and my hobby project was taking over my life. I dropped out of high school to work on Modrinth full time. I raised money, hired a team, and moved to New York. We launched creator monetization, the Modrinth App, analytics, and more.
The site was growing like crazy, but we also accumulated a lot of technical debt and made a lot of mistakes along the way. In early 2024, I [returned $800k to our investors](/news/article/capital-return/) because the venture path wasn't right for us. That was painful, but the right thing to do.
### What brought me here
Since then, I decided to go to college. I initially thought I could keep running Modrinth day-to-day, but school quickly started to absorb my life, and I found myself wanting to spend more time learning in classes and being involved in the campus community than doing things for Modrinth.
I hired Josh to help manage the team and as he excelled at every responsibility I gave him (and honestly did some things way better than I ever could), he quickly evolved into running the entire team day-to-day.
Through his leadership, we shipped new creator withdrawal options, server projects, and many app improvements and fixes. Weve also taken Modrinth Hosting fully in-house, bringing more stability to the platform and expanding into new regions around the world.
But, there were still problems, mostly stemming from me not being committed or involved. I would take forever to do basic responsibilities, from hiring new content moderators and updating our very outdated legal documents, to giving feedback on new features we were releasing. I felt like I was doing a disservice to the community and the team by not being a present leader.
On top of that, Modrinth has been struggling from a lack of resources in ways that are starting to show. Content review wait times, website instability, API tech debt, and an increasing amount of bugs we hardly have time to keep up with. Things that we simply need more attention and resources to fix.
I started looking for someone new to support Josh and the team.
It wasnt hard to find someone who wanted to buy Modrinth, but I was not interested in selling to some company that doesnt care about the game and the community as much as we do. Thats what led me to Raf and Flo, the founders of [Spark Universe](https://sparkuniverse.com/).
### Why Spark?
I know what some of you are thinking. Spark Universe, are they really the right people for Modrinth and do they actually care about this community?
I first met Raf and Flo in 2023. We were talking about integrating a mod browser into Essential Mod. They asked how they could contribute to creator payouts for every mod downloaded through their integration. Nobody had ever asked that question before, not even once, and that really stuck with me.
Across everything that Spark has made, you can tell that a lot of skill and craft was involved. I've always looked up to their design ethos and consistent quality in everything they make.
On Bedrock, Spark has created amazing content like [RealismCraft](https://sparkuniverse.com/project/realism-craft) and the recently announced [Aether Legends](https://aetherlegends.gg/) in partnership with the original mod authors. On Java, Spark have built the [Essential Mod](https://modrinth.com/mod/essential), which makes playing with friends easier, and introduced free peer-to-peer hosting to the community over 4 years ago.
I know some people really don't like Essential. And while I think it's very cool what theyve brought to Minecraft, and how theyve built a modding team that pays all of its team livable salaries, it's fine if you still don't like them after this. Modrinth will stay an independent team and project, and there are no plans to ever merge the two.
When I visited the Spark team last year, I met over 50 people in person and saw for myself how much passion they have for creating in Minecraft. These people have done it all: Builds, mini-games, servers, texture packs, add-ons, mods, YT shows, animations, and more. Many of them have been creating in Minecraft for over 10 years.
If Modrinth was ever going to have a new home, it had to be with people who get it because they've lived it.
### I know this is hard to hear
I know some of you will find this news disappointing: I understand. Modrinth is the independent community-first alternative and joining another company sounds scary.
I truly believe that this was the best thing to do, but Im not asking for your blind trust. Watch what happens, hold us accountable, and give the team at Spark Universe the chance to show you who they are. They know they have to earn your trust. Theyre ready to do that work.
### What's not changing
It is a cliche that companies, upon acquisition, will say that nothing is going to change. And it always does. Modrinth _will_ change, because it's not perfect. But our mission and values will not.
- **Our mission stays the same.** A transparent, creator-first modding platform. We are building the best place to share and grow your mods.
- **Modrinth stays open source.** Our code is available for anyone to use, adapt, and contribute to. Its core to our mission and Spark will not be changing that.
- **We are not merging with Essential.** Modrinth and Essential have unique areas of focus and independent teams. Essential will never be automatically installed to your instances or favored when browsing for mods.
- **The team isn't going anywhere.** Modrinth continues to be built by the same people. Josh stays leading the team. Raf and Flo are here to support our team, not to have someone from Spark run Modrinth.
- **I'll still be around too.** I will continue to help the team with making Modrinth better. I'm still in all of our team channels, giving input on various things, and helping behind the scenes to make Modrinth better and better!
### How Spark is helping
The acquisition closed back in February. Raf and Flo have spent a lot of time getting more familiar with how the Modrinth team and the platform operate, and have been helping us already.
- **Growing the team.** Modrinth has been growing so much that weve definitely felt stretch thin. Spark is helping us expand the team in content review, support and engineering so we can resolve some of these growing pains.
- **Supporting team members.** Currently a lot of the team are contractors because employing a global and remote team is really hard. Spark will help us offer employment and benefits to the team! Everyone has poured a lot of love into Modrinth so its awesome to support them further.
- **Continuing our vision.** Spark is here to help us do more of what were already doing. Building a creator-first modding platform together with the community. We want to be the best place to share and grow your mods.
### Thank you everyone!
I've been working on Modrinth since I was thirteen. It started as a side project and turned into something that tens of millions of people use. That still feels surreal.
Thank you to every creator who has published their work here. Thank you to all the players who find their mods on Modrinth! To everyone who's [contributed code](https://github.com/modrinth/code/graphs/contributors?all=1), reported bugs, translated the site, shared Modrinth with friends, or just hung out in the Discord: This has always been a community project and it always will be.
Watch, stay involved, and keep holding us to the standard you deserve.
— Jai, Founder of Modrinth
<div id="spark-live-widget-embed"></div>
+25
View File
@@ -10,6 +10,31 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-16T18:58:45+00:00`,
product: 'app',
version: '0.14.7',
body: `## Added
- Warning modal before deleting content that other content in the instance depends on.
## Changed
- Improved folder filtering when exporting a modpack from an instance.
- Improved error messages when parts of the Modrinth API are unavailable.
## Fixed
- Fixed the Content tab showing updates for installed content when the recommended version used the same file as installed.
- Fixed automatically installed dependencies not appearing as installed when installing content from the Discover page.
- Fixed the search filter for older game versions.
- Fixed bulk action content modals sometimes saying no projects were selected.
- Fixed the Content tab multi-select bar shifting when modals were opened.`,
},
{
date: `2026-06-16T18:58:45+00:00`,
product: 'web',
body: `## Fixed
- Fixed Babric project versions being detected as Fabric versions.
- Fixed the search filter for older game versions.`,
},
{
date: `2026-06-11T19:05:19+00:00`,
product: 'app',
+2
View File
@@ -14,6 +14,7 @@ import { article as design_refresh } from "./design_refresh";
import { article as download_adjustment } from "./download_adjustment";
import { article as free_server_medal } from "./free_server_medal";
import { article as introducing_server_projects } from "./introducing_server_projects";
import { article as joining_spark_universe } from "./joining_spark_universe";
import { article as knossos_v2_1_0 } from "./knossos_v2_1_0";
import { article as licensing_guide } from "./licensing_guide";
import { article as modpack_changes } from "./modpack_changes";
@@ -56,6 +57,7 @@ export const articles = [
download_adjustment,
free_server_medal,
introducing_server_projects,
joining_spark_universe,
knossos_v2_1_0,
licensing_guide,
modpack_changes,
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
// AUTO-GENERATED FILE - DO NOT EDIT
export const article = {
html: () => import(`./joining_spark_universe.content`).then(m => m.html),
title: "Modrinth joins Spark Universe",
summary: "The next chapter. What it means and why we think its right for Modrinth.",
date: "2026-06-15T14:00:00.000Z",
slug: "joining-spark-universe",
authors: ["MpxzqsyW"],
unlisted: false,
thumbnail: true,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { type Component, computed } from 'vue'
import SparkLiveWidget from './SparkLiveWidget.vue'
import SparkLiveWidgetEmbed from './SparkLiveWidgetEmbed.vue'
const ARTICLE_WIDGETS: Record<string, Component> = {
'spark-live-widget': SparkLiveWidget,
'spark-live-widget-embed': SparkLiveWidgetEmbed,
}
type ArticleBodyPart = { type: 'html'; content: string } | { type: 'widget'; id: string }
function parseArticleHtml(html: string): ArticleBodyPart[] {
const widgetIds = Object.keys(ARTICLE_WIDGETS)
if (widgetIds.length === 0) {
return [{ type: 'html', content: html }]
}
const pattern = new RegExp(`<div id="(${widgetIds.join('|')})"></div>`, 'g')
const parts: ArticleBodyPart[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(html)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'html', content: html.slice(lastIndex, match.index) })
}
parts.push({ type: 'widget', id: match[1] })
lastIndex = pattern.lastIndex
}
if (lastIndex < html.length) {
parts.push({ type: 'html', content: html.slice(lastIndex) })
}
return parts.length > 0 ? parts : [{ type: 'html', content: html }]
}
const props = defineProps<{
html: string
}>()
const parts = computed(() => parseArticleHtml(props.html))
</script>
<template>
<div
v-if="parts.length === 1 && parts[0].type === 'html'"
class="markdown-body"
v-html="parts[0]?.content"
/>
<div v-else class="markdown-body">
<template v-for="(part, index) in parts" :key="index">
<div v-if="part.type === 'html'" v-html="part.content" />
<component :is="ARTICLE_WIDGETS[part.id]" v-else />
</template>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { VideoIcon } from '@modrinth/assets'
withDefaults(
defineProps<{
embed?: boolean
}>(),
{
embed: false,
},
)
</script>
<template>
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3 overflow-hidden">
<div class="p-4 flex flex-col gap-3">
<div class="flex items-center gap-2">
<VideoIcon class="size-6 text-brand" />
<span class="text-contrast font-medium">
We're hosting a live chat with Spark later today to answer all of your questions!
</span>
</div>
<span>
Ask questions for us to answer in our
<a
href="https://discord.modrinth.com"
target="_blank"
class="text-brand font-semibold hover:underline"
>Discord server</a
>.
</span>
<span
>Tune in live on June 15, 11am PST / 2pm EST / 7pm BST / 8pm CEST over on our
<a
href="https://www.youtube.com/live/p1Dg-fud0TQ"
target="_blank"
class="text-brand font-semibold hover:underline"
>YouTube channel</a
>.</span
>
</div>
<div
v-if="embed"
style="left: 0; width: 100%; height: 0; position: relative; padding-bottom: 56.25%"
>
<iframe
src="https://www.youtube.com/embed/p1Dg-fud0TQ"
style="top: 0; left: 0; width: 100%; height: 100%; position: absolute; border: 0"
allowfullscreen
scrolling="no"
allow="
accelerometer *;
clipboard-write *;
encrypted-media *;
gyroscope *;
picture-in-picture *;
web-share *;
"
referrerpolicy="strict-origin"
></iframe>
</div>
</div>
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import SparkLiveWidget from './SparkLiveWidget.vue'
</script>
<template>
<SparkLiveWidget embed />
</template>
@@ -1,3 +1,5 @@
export { default as ArticleBody } from './ArticleBody.vue'
export { default as ContentListPanel } from './ContentListPanel.vue'
export type { Article as NewsArticle } from './NewsArticleCard.vue'
export { default as NewsArticleCard } from './NewsArticleCard.vue'
export { default as SparkLiveWidget } from './SparkLiveWidget.vue'
@@ -61,6 +61,7 @@ interface Props {
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
inline?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -82,12 +83,14 @@ const props = withDefaults(defineProps<Props>(), {
showCheckbox: false,
hideDelete: false,
hideActions: false,
inline: false,
})
const selected = defineModel<boolean>('selected')
const emit = defineEmits<{
'update:enabled': [value: boolean]
select: [value: boolean, event?: MouseEvent]
delete: [event: MouseEvent]
update: []
switchVersion: []
@@ -124,8 +127,10 @@ const deleteHovered = ref(false)
<template>
<div
role="row"
class="flex h-[74px] items-center justify-between gap-4 px-3"
class="flex items-center justify-between"
:class="{
'h-[74px] gap-4 px-3': !inline,
'gap-3': inline,
'opacity-50 grayscale': disabled && !installing,
'opacity-50': installing,
}"
@@ -141,7 +146,7 @@ const deleteHovered = ref(false)
:model-value="selected ?? false"
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
class="shrink-0"
@update:model-value="selected = $event"
@update:model-value="(value, event) => emit('select', value, event)"
/>
<div
@@ -150,7 +155,7 @@ const deleteHovered = ref(false)
>
<div
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
class="relative shrink-0"
class="relative flex shrink-0 items-center"
>
<Avatar
:src="project.icon_url"
@@ -180,6 +185,7 @@ const deleteHovered = ref(false)
>
{{ project.title }}
</AutoLink>
<slot name="title-badges" />
<Tooltip
v-if="isClientOnly"
theme="dismissable-prompt"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, toRef } from 'vue'
import { computed, getCurrentInstance, ref, toRef } from 'vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import { useVIntl } from '#ui/composables/i18n'
@@ -119,26 +119,14 @@ function toggleSelectAll() {
}
const lastSelectedIndex = ref<number | null>(null)
const shiftHeld = ref(false)
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Shift') shiftHeld.value = true
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === 'Shift') shiftHeld.value = false
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
})
function toggleItemSelection(itemId: string, selected: boolean, index?: number) {
if (selected && shiftHeld.value && lastSelectedIndex.value !== null && index !== undefined) {
function toggleItemSelection(
itemId: string,
selected: boolean,
index?: number,
event?: MouseEvent,
) {
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
@@ -297,8 +285,9 @@ function handleSort(column: ContentCardTableSortColumn) {
'border-0 border-t border-solid border-surface-4',
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="
(val) => toggleItemSelection(item.id, val ?? false, visibleRange.start + idx)
@select="
(val, event) =>
toggleItemSelection(item.id, val ?? false, visibleRange.start + idx, event)
"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@@ -355,7 +344,7 @@ function handleSort(column: ContentCardTableSortColumn) {
'border-0 border-t border-solid border-surface-4',
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false, index)"
@select="(val, event) => toggleItemSelection(item.id, val ?? false, index, event)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@update="emit('update', item.id)"
@@ -141,7 +141,7 @@ const bulkProgressMessage = computed(() => {
</script>
<template>
<FloatingActionBar :shown="shown" :aria-label="ariaLabel">
<FloatingActionBar :shown="shown" :aria-label="ariaLabel" hide-when-modal-open>
<div class="flex items-center gap-0.5">
<div
v-if="selectedItems.length > 0"
@@ -45,7 +45,7 @@
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -100,7 +100,8 @@ const buttonsDisabled = ref(false)
const visibleCount = ref(props.count)
const visibleBackupTip = ref(props.backupTip)
function show() {
async function show() {
await nextTick()
visibleCount.value = props.count
visibleBackupTip.value = props.backupTip
modal.value?.show()
@@ -6,15 +6,12 @@
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
"
:fade="props.variant === 'server' ? 'warning' : 'danger'"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition
:type="props.variant === 'server' ? 'warning' : 'critical'"
:header="formatMessage(messages.admonitionHeader)"
>
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
@@ -32,7 +29,7 @@
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
<ButtonStyled color="orange">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@@ -54,7 +51,7 @@
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -113,7 +110,8 @@ const buttonsDisabled = ref(false)
const visibleCount = ref(props.count)
const visibleItemType = ref(props.itemType)
function show() {
async function show() {
await nextTick()
visibleCount.value = props.count
visibleItemType.value = props.itemType
modal.value?.show()
@@ -0,0 +1,371 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="danger"
max-width="560px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{
visibleItems.length === 1
? formatMessage(messages.singleAdmonitionBody, {
project:
visibleItems[0]?.project.title ?? formatMessage(commonMessages.unknownLabel),
context: contextLabel,
})
: formatMessage(messages.bulkAdmonitionBody, { context: contextLabel })
}}
</Admonition>
<div v-if="visibleItems.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.deletingLabel) }}</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDeletingTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="deletingListRef"
class="flex flex-col gap-2 overflow-y-auto max-h-[212px]"
@scroll="checkDeletingScrollState"
>
<div v-for="item in visibleItems" :key="item.id" :class="modalContentCardClasses">
<ContentCardItem
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
hide-actions
inline
/>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDeletingBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
<div v-if="visibleDependents.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.affectedDependentsLabel, { count: visibleDependents.length })
}}</span>
<div class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDependentTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="dependentListRef"
class="flex max-h-[212px] flex-col gap-2 overflow-y-auto"
@scroll="checkDependentScrollState"
>
<div
v-for="dependent in visibleDependents"
:key="dependent.item.id"
:class="modalContentCardClasses"
>
<ContentCardItem
:project="dependent.item.project"
:project-link="dependent.item.projectLink"
:version="dependent.item.version"
:version-link="dependent.item.versionLink"
:owner="dependent.item.owner"
hide-actions
inline
>
<template #title-badges>
<span class="flex min-w-0 flex-wrap items-center gap-1">
<span
v-for="dependency in dependent.dependencies"
:key="dependency.id"
:title="dependency.project.title"
>
<span class="truncate text-xs text-secondary mr-0.5"
>({{ dependency.project.title }})</span
>
</span>
</span>
</template>
</ContentCardItem>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showDependentBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whatHappensLabel)
}}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li class="leading-6 marker:text-secondary">
{{ formatMessage(messages.effectDependentContent) }}
</li>
<li class="leading-6 marker:text-secondary">
{{ formatMessage(messages.effectInstance, { context: contextLabel }) }}
</li>
</ul>
<Checkbox
v-model="disableDependentsAfterDeleting"
:label="formatMessage(messages.disableDependentsLabel)"
label-class="font-medium text-primary"
class="mt-1"
/>
</div>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupName"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<TrashIcon aria-hidden="true" />
{{ deleteButtonLabel }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useScrollIndicator } from '#ui/composables/scroll-indicator'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import type { ContentCardTableItem } from '../../types'
import ContentCardItem from '../ContentCardItem.vue'
import InlineBackupCreator from './InlineBackupCreator.vue'
export interface ContentDependencyWarningDependent {
item: ContentCardTableItem
dependencies: ContentCardTableItem[]
}
const props = withDefaults(
defineProps<{
items?: ContentCardTableItem[]
dependents?: ContentDependencyWarningDependent[]
itemType: string
variant?: 'instance' | 'server'
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
items: () => [],
dependents: () => [],
variant: 'instance',
backupTip: undefined,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
const emit = defineEmits<{
(e: 'delete', disableDependentsAfterDeleting: boolean): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.dependency-warning.header',
defaultMessage: 'Dependency warning',
},
admonitionHeader: {
id: 'content.dependency-warning.admonition-header',
defaultMessage: 'This content is required by other content',
},
singleAdmonitionBody: {
id: 'content.dependency-warning.single-admonition-body',
defaultMessage:
'{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly.',
},
bulkAdmonitionBody: {
id: 'content.dependency-warning.bulk-admonition-body',
defaultMessage:
'Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly.',
},
deletingLabel: {
id: 'content.dependency-warning.deleting-label',
defaultMessage: 'Deleting',
},
affectedDependentsLabel: {
id: 'content.dependency-warning.affected-dependents-label',
defaultMessage: 'Affected {count, plural, one {project} other {projects}}',
},
whatHappensLabel: {
id: 'content.dependency-warning.what-happens-label',
defaultMessage: 'What happens?',
},
effectDependentContent: {
id: 'content.dependency-warning.effect-dependent-content',
defaultMessage: 'Dependent content may fail to load or may disable itself',
},
effectInstance: {
id: 'content.dependency-warning.effect-instance',
defaultMessage: 'Your {context} may crash, refuse to start, or behave unexpectedly',
},
deleteAnywayButton: {
id: 'content.dependency-warning.delete-anyway-button',
defaultMessage: 'Delete anyway',
},
deleteManyAnywayButton: {
id: 'content.dependency-warning.delete-many-anyway-button',
defaultMessage: 'Delete {count, number} {itemType} anyway',
},
disableDependentsLabel: {
id: 'content.dependency-warning.disable-dependents-label',
defaultMessage: 'Disable dependents after deleting',
},
instanceContext: {
id: 'content.dependency-warning.context.instance',
defaultMessage: 'instance',
},
serverContext: {
id: 'content.dependency-warning.context.server',
defaultMessage: 'server',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const deletingListRef = ref<HTMLElement | null>(null)
const dependentListRef = ref<HTMLElement | null>(null)
const visibleItems = ref<ContentCardTableItem[]>(props.items)
const visibleDependents = ref<ContentDependencyWarningDependent[]>(props.dependents)
const visibleItemType = ref(props.itemType)
const disableDependentsAfterDeleting = ref(false)
const buttonsDisabled = ref(false)
const modalContentCardClasses = 'rounded-xl border border-solid border-surface-5 p-4 !bg-surface-2'
const {
showTopFade: showDeletingTopFade,
showBottomFade: showDeletingBottomFade,
checkScrollState: checkDeletingScrollState,
forceCheck: forceCheckDeletingScroll,
} = useScrollIndicator(deletingListRef)
const {
showTopFade: showDependentTopFade,
showBottomFade: showDependentBottomFade,
checkScrollState: checkDependentScrollState,
forceCheck: forceCheckDependentScroll,
} = useScrollIndicator(dependentListRef)
const contextLabel = computed(() =>
formatMessage(props.variant === 'server' ? messages.serverContext : messages.instanceContext),
)
const backupName = computed(() =>
props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion',
)
const deleteButtonLabel = computed(() => {
if (visibleItems.value.length <= 1) return formatMessage(messages.deleteAnywayButton)
return formatMessage(messages.deleteManyAnywayButton, {
count: visibleItems.value.length,
itemType: formatContentTypeSentence(
formatMessage,
visibleItemType.value,
visibleItems.value.length,
),
})
})
async function show() {
await nextTick()
visibleItems.value = props.items
visibleDependents.value = props.dependents
visibleItemType.value = props.itemType
disableDependentsAfterDeleting.value = false
buttonsDisabled.value = false
modal.value?.show()
await nextTick()
forceCheckDeletingScroll()
forceCheckDependentScroll()
}
function hide() {
modal.value?.hide()
}
function confirm() {
if (props.actionDisabled || buttonsDisabled.value) return
modal.value?.hide()
emit('delete', disableDependentsAfterDeleting.value)
}
defineExpose({
show,
hide,
})
</script>
@@ -8,6 +8,7 @@ export { default as ConfirmModpackUpdateModal } from './components/modals/Confir
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
export { default as ConfirmUnlinkModal } from './components/modals/ConfirmUnlinkModal.vue'
export { default as ContentDependencyWarningModal } from './components/modals/ContentDependencyWarningModal.vue'
export type {
ContentInstallInstance,
ContentInstallProjectInfo,
@@ -34,6 +34,7 @@ import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
getClientWarningType,
isClientOnlyEnvironment,
@@ -319,21 +320,73 @@ const hasOutdatedProjects = computed(() => {
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
const pendingDependencyWarningDependents = ref<
Array<{
item: ContentCardTableItem
dependencies: ContentCardTableItem[]
}>
>([])
const pendingDependencyWarningDisableTargets = ref<ContentItem[]>([])
function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
}
function mapToDisplayItem(item: ContentItem) {
return {
...ctx.mapToTableItem(item),
id: getItemId(item),
}
}
function showBulkDeleteModal(event?: MouseEvent) {
pendingDeletionItems.value = [...selectedItems.value]
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
if (items.length === 0) return
pendingDeletionItems.value = items
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
pendingDependencyWarningDisableTargets.value = []
const deletingIds = new Set(items.map(getItemId))
const warning = ctx.getDeleteDependencyWarning
? await Promise.resolve()
.then(() => ctx.getDeleteDependencyWarning!(items))
.catch(() => null)
: null
if (warning) {
const remainingDependents = warning.dependents.filter(
(dependent) => !deletingIds.has(getItemId(dependent.item)),
)
if (remainingDependents.length === 0) {
showDeletionConfirmation(event)
return
}
const relevantDependencyIds = new Set(
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
)
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
if (warningItems.length === 0) {
showDeletionConfirmation(event)
return
}
pendingDependencyWarningItems.value = warningItems.map(mapToDisplayItem)
pendingDependencyWarningDependents.value = remainingDependents.map((dependent) => ({
item: mapToDisplayItem(dependent.item),
dependencies: dependent.dependencies
.filter((dependency) => relevantDependencyIds.has(getItemId(dependency)))
.map(mapToDisplayItem),
}))
pendingDependencyWarningDisableTargets.value = remainingDependents.map(
(dependent) => dependent.item,
)
contentDependencyWarningModal.value?.show()
return
}
showDeletionConfirmation(event)
}
function showDeletionConfirmation(event?: MouseEvent) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
@@ -341,6 +394,51 @@ function showBulkDeleteModal(event?: MouseEvent) {
}
}
async function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
await promptDeleteItems([item], event)
}
}
async function showBulkDeleteModal(event?: MouseEvent) {
await promptDeleteItems([...selectedItems.value], event)
}
async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: boolean) {
if (disableDependentsAfterDeleting) {
pendingDependencyWarningDisableTargets.value =
pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
} else {
pendingDependencyWarningDisableTargets.value = []
}
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
await confirmDelete()
}
async function disablePendingDependencyWarningDependents() {
const items = pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
pendingDependencyWarningDisableTargets.value = []
if (items.length === 0) return
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(items)
return
}
for (const item of items) {
const id = getItemId(item)
markChanging(id)
try {
await ctx.toggleEnabled(item)
} finally {
unmarkChanging(id)
}
}
}
async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
@@ -353,6 +451,7 @@ async function confirmDelete() {
bulkWaiting.value = true
try {
await ctx.bulkDeleteItems(itemsToDelete)
await disablePendingDependencyWarningDependents()
} finally {
clearSelection()
isBulkOperating.value = false
@@ -369,6 +468,7 @@ async function confirmDelete() {
try {
await ctx.deleteItem(item)
removeFromSelection(id)
await disablePendingDependencyWarningDependents()
} finally {
unmarkChanging(id)
}
@@ -384,6 +484,7 @@ async function confirmDelete() {
},
{ onComplete: clearSelection },
)
await disablePendingDependencyWarningDependents()
}
async function handleToggleEnabledById(id: string, _value: boolean) {
@@ -879,6 +980,17 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ContentDependencyWarningModal
ref="contentDependencyWarningModal"
:items="pendingDependencyWarningItems"
:dependents="pendingDependencyWarningDependents"
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDependencyWarningDelete"
/>
<ConfirmBulkUpdateModal
v-if="hasBulkUpdateSupport"
ref="confirmBulkUpdateModal"
@@ -25,6 +25,14 @@ export interface ContentModpackData {
disabledText?: string
}
export interface ContentDependencyWarning {
items: ContentItem[]
dependents: Array<{
item: ContentItem
dependencies: ContentItem[]
}>
}
export interface ContentManagerContext {
// Data
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
@@ -55,6 +63,9 @@ export interface ContentManagerContext {
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
getDeleteDependencyWarning?: (
items: ContentItem[],
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
// Update support (optional per-platform)
hasUpdateSupport: boolean
+42
View File
@@ -377,6 +377,48 @@
"content.confirm-unlink.unlink-button": {
"defaultMessage": "Unlink"
},
"content.dependency-warning.admonition-header": {
"defaultMessage": "This content is required by other content"
},
"content.dependency-warning.affected-dependents-label": {
"defaultMessage": "Affected {count, plural, one {project} other {projects}}"
},
"content.dependency-warning.bulk-admonition-body": {
"defaultMessage": "Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly."
},
"content.dependency-warning.context.instance": {
"defaultMessage": "instance"
},
"content.dependency-warning.context.server": {
"defaultMessage": "server"
},
"content.dependency-warning.delete-anyway-button": {
"defaultMessage": "Delete anyway"
},
"content.dependency-warning.delete-many-anyway-button": {
"defaultMessage": "Delete {count, number} {itemType} anyway"
},
"content.dependency-warning.deleting-label": {
"defaultMessage": "Deleting"
},
"content.dependency-warning.disable-dependents-label": {
"defaultMessage": "Disable dependents after deleting"
},
"content.dependency-warning.effect-dependent-content": {
"defaultMessage": "Dependent content may fail to load or may disable itself"
},
"content.dependency-warning.effect-instance": {
"defaultMessage": "Your {context} may crash, refuse to start, or behave unexpectedly"
},
"content.dependency-warning.header": {
"defaultMessage": "Dependency warning"
},
"content.dependency-warning.single-admonition-body": {
"defaultMessage": "{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly."
},
"content.dependency-warning.what-happens-label": {
"defaultMessage": "What happens?"
},
"content.diff-modal.added-count": {
"defaultMessage": "{count} added"
},
@@ -0,0 +1,289 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentDependencyWarningModal from '../../layouts/shared/content-tab/components/modals/ContentDependencyWarningModal.vue'
import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types'
const fabricApiItem: ContentCardTableItem = {
id: 'fabric-api',
project: {
id: 'P7dR8mSH',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
projectLink: '/project/fabric-api',
version: {
id: 'Lwa1Q6e4',
version_number: '0.141.3+1.21.6',
file_name: 'fabric-api-0.141.3+1.21.6.jar',
},
versionLink: '/project/fabric-api/version/Lwa1Q6e4',
owner: {
id: 'fabricmc',
name: 'FabricMC',
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
type: 'organization',
link: '/organization/fabricmc',
},
enabled: true,
}
const sodiumItem: ContentCardTableItem = {
id: 'sodium',
project: {
id: 'AANobbMI',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
projectLink: '/project/sodium',
version: {
id: 'sodium-version',
version_number: 'mc1.21.6-0.6.13-fabric',
file_name: 'sodium-fabric-0.6.13+mc1.21.6.jar',
},
versionLink: '/project/sodium/version/sodium-version',
owner: {
id: 'jellysquid3',
name: 'jellysquid3',
type: 'user',
link: '/user/jellysquid3',
},
enabled: true,
}
const irisItem: ContentCardTableItem = {
id: 'iris',
project: {
id: 'YL57xq9U',
slug: 'iris',
title: 'Iris Shaders',
icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png',
},
projectLink: '/project/iris',
version: {
id: 'iris-version',
version_number: '1.8.12+1.21.6-fabric',
file_name: 'iris-fabric-1.8.12+mc1.21.6.jar',
},
versionLink: '/project/iris/version/iris-version',
owner: {
id: 'coderbot',
name: 'coderbot',
type: 'user',
link: '/user/coderbot',
},
enabled: true,
}
const lithiumItem: ContentCardTableItem = {
id: 'lithium',
project: {
id: 'gvQqBUqZ',
slug: 'lithium',
title: 'Lithium',
icon_url:
'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp',
},
projectLink: '/project/lithium',
version: {
id: 'lithium-version',
version_number: 'mc1.21.6-0.16.2-fabric',
file_name: 'lithium-fabric-0.16.2+mc1.21.6.jar',
},
versionLink: '/project/lithium/version/lithium-version',
owner: {
id: 'caffeinemc',
name: 'CaffeineMC',
type: 'organization',
link: '/organization/caffeinemc',
},
enabled: true,
}
const continuityItem: ContentCardTableItem = {
id: 'continuity',
project: {
id: '1IjD5062',
slug: 'continuity',
title: 'Continuity',
icon_url: 'https://cdn.modrinth.com/data/1IjD5062/icon.png',
},
projectLink: '/project/continuity',
version: {
id: 'continuity-version',
version_number: '3.0.1-beta.2+1.21.6',
file_name: 'continuity-3.0.1-beta.2+1.21.6.jar',
},
versionLink: '/project/continuity/version/continuity-version',
owner: {
id: 'pepper-bell',
name: 'Pepper_Bell',
type: 'user',
link: '/user/pepper-bell',
},
enabled: true,
}
const capeProviderItem: ContentCardTableItem = {
id: 'cape-provider',
project: {
id: 'cape-provider',
slug: 'cape-provider',
title: 'Cape Provider',
icon_url: null,
},
projectLink: '/project/cape-provider',
version: {
id: 'cape-provider-version',
version_number: '5.4.2',
file_name: 'cape-provider-5.4.2.jar',
},
versionLink: '/project/cape-provider/version/cape-provider-version',
owner: {
id: 'litetex',
name: 'litetex',
type: 'user',
link: '/user/litetex',
},
enabled: true,
}
const meta = {
title: 'Instances/ContentDependencyWarningModal',
component: ContentDependencyWarningModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ContentDependencyWarningModal>
export default meta
type Story = StoryObj<typeof meta>
export const InstanceDependency: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
fabricApiItem,
sodiumItem,
irisItem,
continuityItem,
lithiumItem,
capeProviderItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete dependency</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[fabricApiItem]"
item-type="project"
:dependents="[
{ item: sodiumItem, dependencies: [fabricApiItem] },
{ item: irisItem, dependencies: [fabricApiItem] },
{ item: continuityItem, dependencies: [fabricApiItem] },
{ item: lithiumItem, dependencies: [fabricApiItem] },
{ item: capeProviderItem, dependencies: [fabricApiItem] },
]"
@delete="handleDelete"
/>
</div>
`,
}),
}
export const ServerDependency: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
lithiumItem,
sodiumItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete server dependency</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Server dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[lithiumItem]"
item-type="project"
:dependents="[{ item: sodiumItem, dependencies: [lithiumItem] }]"
variant="server"
@delete="handleDelete"
/>
</div>
`,
}),
}
export const BulkDependencies: Story = {
render: () => ({
components: { ButtonStyled, ContentDependencyWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
const deleted = ref(false)
function handleDelete() {
deleted.value = true
}
return {
modalRef,
deleted,
handleDelete,
fabricApiItem,
lithiumItem,
sodiumItem,
irisItem,
continuityItem,
capeProviderItem,
}
},
template: /* html */ `
<div class="flex flex-col items-center gap-4">
<ButtonStyled color="orange">
<button @click="modalRef?.show()">Delete selected dependencies</button>
</ButtonStyled>
<p v-if="deleted" class="m-0 text-sm text-secondary">Bulk dependency deletion confirmed</p>
<ContentDependencyWarningModal
ref="modalRef"
:items="[fabricApiItem, lithiumItem]"
item-type="project"
:dependents="[
{ item: sodiumItem, dependencies: [fabricApiItem, lithiumItem] },
{ item: irisItem, dependencies: [fabricApiItem] },
{ item: continuityItem, dependencies: [fabricApiItem] },
{ item: capeProviderItem, dependencies: [lithiumItem] },
]"
@delete="handleDelete"
/>
</div>
`,
}),
}
+10 -10
View File
@@ -484,7 +484,7 @@ export function useSearch(
}
orGroups[field].push(val)
} else {
parts.push(`${field} = ${enquoteNonBools(val)}`)
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
}
}
}
@@ -492,15 +492,15 @@ export function useSearch(
for (const [field, values] of Object.entries(orGroups)) {
if (values.length === 1) {
const val = values[0]
parts.push(`${field} = ${enquoteNonBools(val)}`)
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
} else {
const quoted = values.map(enquoteNonBools).join(', ')
const quoted = values.map(formatSearchFilterValue).join(', ')
parts.push(`${field} IN [${quoted}]`)
}
}
for (const [field, values] of Object.entries(negativeByType)) {
const quoted = values.map(enquoteNonBools).join(', ')
const quoted = values.map(formatSearchFilterValue).join(', ')
parts.push(`${field} NOT IN [${quoted}]`)
}
@@ -514,11 +514,11 @@ export function useSearch(
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
if (envGroup.length === 1) {
const [field, val] = envGroup[0].split(':')
parts.push(`${field} = ${enquoteNonBools(val)}`)
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
} else if (envGroup.length > 1) {
const conditions = envGroup.map((f) => {
const [field, val] = f.split(':')
return `${field} = ${enquoteNonBools(val)}`
return `${field} = ${formatSearchFilterValue(val)}`
})
parts.push(`(${conditions.join(' OR ')})`)
}
@@ -527,9 +527,9 @@ export function useSearch(
// Project types
const mappedProjectTypes = projectTypes.value.map(mapProjectTypeToSearch)
if (mappedProjectTypes.length === 1) {
parts.push(`project_types = ${enquoteNonBools(mappedProjectTypes[0])}`)
parts.push(`project_types = ${formatSearchFilterValue(mappedProjectTypes[0])}`)
} else if (mappedProjectTypes.length > 1) {
const quoted = mappedProjectTypes.map(enquoteNonBools).join(', ')
const quoted = mappedProjectTypes.map(formatSearchFilterValue).join(', ')
parts.push(`project_types IN [${quoted}]`)
}
@@ -792,11 +792,11 @@ function getEnvironmentFilterGroups(client: boolean, server: boolean): string[][
return groups
}
function enquoteNonBools(value: string): string {
export function formatSearchFilterValue(value: string): string {
if (value === 'true' || value === 'false') {
return value
}
return `"${value}"`
return `\`${value}\``
}
function getOptionValue(option: FilterOption, negative?: boolean): string {
+5 -4
View File
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
import type { FilterType, FilterValue, SortType, Tags } from './search'
import { formatSearchFilterValue } from './search'
import { formatCategory, formatCategoryHeader } from './tag-messages'
export const SERVER_REGIONS = {
@@ -363,11 +364,11 @@ export function useServerSearch(opts: {
const included = matched.filter((f) => !f.negative)
const excluded = matched.filter((f) => f.negative)
if (included.length > 0) {
const values = included.map((f) => `"${f.option}"`).join(', ')
const values = included.map((f) => formatSearchFilterValue(f.option)).join(', ')
parts.push(`${field} IN [${values}]`)
}
if (excluded.length > 0) {
const values = excluded.map((f) => `"${f.option}"`).join(', ')
const values = excluded.map((f) => formatSearchFilterValue(f.option)).join(', ')
parts.push(`${field} NOT IN [${values}]`)
}
}
@@ -389,11 +390,11 @@ export function useServerSearch(opts: {
.map((filter) => filter.projectId)
if (includedProjectIds.length > 0) {
const values = includedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
const values = includedProjectIds.map(formatSearchFilterValue).join(', ')
parts.push(`project_id IN [${values}]`)
}
if (excludedProjectIds.length > 0) {
const values = excludedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
const values = excludedProjectIds.map(formatSearchFilterValue).join(', ')
parts.push(`project_id NOT IN [${values}]`)
}
+1 -6
View File
@@ -2,12 +2,7 @@ import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/components/**/*.{js,vue,ts}',
'./src/pages/**/*.{js,vue,ts}',
'./src/stories/**/*.{js,vue,ts,mdx}',
'./.storybook/**/*.{ts,js}',
],
content: ['./src/**/*.{js,vue,ts,mdx}', './.storybook/**/*.{ts,js}'],
presets: [preset],
}