You've already forked AstralRinth
Merge tag 'v0.14.7' into beta
v0.14.7
This commit is contained in:
Vendored
+1
-6
@@ -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 !== '') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
+13
-140
@@ -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 it’s 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 what’s changed.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-4
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 it’s 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 it’s 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. We’ve 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 wasn’t hard to find someone who wanted to buy Modrinth, but I was not interested in selling to some company that doesn’t care about the game and the community as much as we do. That’s 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 they’ve brought to Minecraft, and how they’ve 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 I’m 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. They’re 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. It’s 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 we’ve 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 we’re 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 it’s 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"
|
||||
|
||||
+3
-2
@@ -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
-8
@@ -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()
|
||||
|
||||
+371
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}]`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user