Compare commits

...
Author SHA1 Message Date
François-X. T. 7e1c332905 fix(labrinth): fix routing config for /v3/content 2026-07-07 12:38:56 -04:00
ProspectorandGitHub df8ebbd3e0 feat: add /build route (#6644) 2026-07-07 15:43:26 +00:00
François-Xavier TalbotandGitHub 517c3d2d72 fix(labrinth): fix prefix stripping in mrpack scanning (#6646) 2026-07-07 01:31:07 +00:00
Prospector 7f15772f59 changelog 2026-07-06 15:19:29 -07:00
ProspectorandGitHub 0905ea72f6 fix: project page in app not receiving instance context & port version page to app (#6618)
* fix: project page in app not receiving instance context

* prepr

* feat: port version page to app

* commonize keys

* whoops bring those back
2026-07-06 15:17:25 -07:00
8d20fd82db fix: moderation/projects endpoint slow (#6639)
* fix: moderation/projects endpoint slow

* fix: re-add tech rev toggle off by default

* fix: i18n

* prepare

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
2026-07-06 19:16:52 +00:00
aecsocketandGitHub c831e38e32 fix: file scans only consider published files as on-platform (#6638) 2026-07-06 18:13:21 +00:00
Calum H.andGitHub 40b8fb3a4a fix: use tanstack for server pinging (#6631) 2026-07-06 18:09:51 +00:00
François-Xavier TalbotandGitHub 977bb2ff58 ci: fix (#6640)
* ci: use apt

* fix(ci): sudo

* bruhj
2026-07-06 17:51:09 +00:00
Calum H.andGitHub 732bdfc79b fix: warm middleware on server-side (#6636) 2026-07-06 17:39:43 +00:00
Calum H.andGitHub 1cce1eb248 fix: strip runtime filters to be authoratative (#6635) 2026-07-06 17:39:17 +00:00
Calum H.andGitHub 76320f227f fix: dont prefetch file contents if it's not editable/viewable (#6632) 2026-07-06 17:06:41 +00:00
Calum H.andGitHub 34228a5b6c fix: use next tick in delete confirm (#6633) 2026-07-06 17:06:10 +00:00
Calum H.andGitHub 6d66aee4ec fix: properly select correct databack versions (#6634) 2026-07-06 16:59:49 +00:00
SychicandGitHub fc7be043c7 fix(labrinth): add invited_by field (#6625) 2026-07-06 16:33:00 +00:00
7fa88e5d4d feat: moderation modpack scan button (#6627)
* feat: moderation modpack scan button

* fix: ui

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-07-06 13:19:39 +00:00
ThatGravyBoatandGitHub 21508e0637 feat: move tech quick actions to dedicated buttons (#6628) 2026-07-06 08:31:51 +00:00
53 changed files with 2487 additions and 1035 deletions
+5 -4
View File
@@ -94,10 +94,11 @@ jobs:
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Install build dependencies
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
packages: cmake libcurl4-openssl-dev
version: v2 # cache key
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
#with:
# packages: cmake libcurl4-openssl-dev
# version: v2 # cache key
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
interface BrowseServerInstance {
id: string
name: string
path: string
}
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
const runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingCacheActive = true
let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
nextPings[hit.project_id] = cachedStatus.ping
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return
if (getFreshCachedServerStatus(queryClient, address)) return
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((error) => {
console.error(`Failed to ping server ${address}:`, error)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
try {
const status = await fetchCachedServerStatus(queryClient, address)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
} catch (error) {
console.error(`Failed to ping server ${address}:`, error)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
.catch(options.handleError)
onUnmounted(() => {
serverPingCacheActive = false
serverPingsActive = false
unlistenProcesses?.()
serverPingCache.clear()
pendingServerPings.clear()
})
return {
@@ -0,0 +1,50 @@
import type { QueryClient } from '@tanstack/vue-query'
import {
get_server_status,
normalizeServerAddress,
type ProtocolVersion,
type ServerStatus,
} from '@/helpers/worlds'
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
if (!protocolVersion) return 'default'
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
}
export function getServerStatusQueryKey(
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return [
'minecraft-server-status',
normalizeServerAddress(address) || address.trim().toLowerCase(),
getProtocolVersionKey(protocolVersion),
] as const
}
export function getFreshCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
const queryKey = getServerStatusQueryKey(address, protocolVersion)
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
return queryClient.getQueryData<ServerStatus>(queryKey)
}
export async function fetchCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return await queryClient.fetchQuery({
queryKey: getServerStatusQueryKey(address, protocolVersion),
queryFn: () => get_server_status(address, protocolVersion),
staleTime: SERVER_STATUS_CACHE_MS,
gcTime: SERVER_STATUS_CACHE_MS,
})
}
@@ -410,12 +410,27 @@
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
"app.project.install-button.switch-version": {
"message": "Switch version"
},
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
"app.project.version.download-in-browser": {
"message": "Download in browser"
},
"app.project.version.installing": {
"message": "Installing"
},
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
+8 -3
View File
@@ -22,6 +22,8 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
useDebugLogger,
useVIntl,
@@ -680,7 +682,6 @@ async function chooseInstanceInstallVersion(
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
projectTypeValue,
)
if (!selectedVersion) {
@@ -763,11 +764,15 @@ function getCardActions(
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: searchState.overriddenProvidedFilterTypes.value,
: stripServerRuntimeInstallOverrides(
searchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
+43 -10
View File
@@ -317,6 +317,10 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
@@ -327,7 +331,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
@@ -372,13 +376,15 @@ const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const statusOnline = computed(() => !!javaServerPingData.value)
const liveServerStatusOnline = ref(false)
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
const recentPlays = computed(
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
watch(
() => router.currentRoute.value,
@@ -390,6 +396,20 @@ watch(
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
liveServerStatusOnline.value = true
loadingServerPing.value = true
}
function resetServerStatus() {
ping.value = undefined
playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.value = false
}
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
@@ -398,9 +418,7 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
resetServerStatus()
const nextInstance = await get(route.params.id as string).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
@@ -429,8 +447,9 @@ async function fetchInstance() {
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
fetchDeferredData()
fetchDeferredData(nextInstance?.id)
if (nextInstance) {
queryClient.prefetchQuery({
@@ -441,18 +460,32 @@ async function fetchInstance() {
}
}
function fetchDeferredData() {
function fetchDeferredData(instanceId?: string) {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
get_server_status(serverAddress)
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
playersOnline.value = status.players?.online
ping.value = status.ping
if (
activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true
})
} else {
@@ -1351,7 +1351,9 @@ provideContentManager({
title: item.file_name.replace('.disabled', ''),
icon_url: null,
},
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
: undefined,
version: item.version ?? {
id: item.file_name,
version_number: formatMessage(commonMessages.unknownLabel),
@@ -1361,6 +1363,7 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.id },
}
: undefined,
owner: item.owner
+51 -8
View File
@@ -116,13 +116,27 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else #actions>
<ButtonStyled size="large" color="brand">
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
<button v-tooltip="installButtonTooltip" disabled>
<CheckIcon />
{{ formatMessage(commonMessages.installedLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="showSwitchVersion" size="large">
<button @click="goToVersions">
<SwapIcon />
{{ formatMessage(messages.switchVersion) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
v-tooltip="installButtonTooltip"
:disabled="installButtonDisabled"
@@ -171,7 +185,9 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
@@ -286,6 +302,7 @@ import {
SelectedProjectsFloatingBar,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -293,8 +310,13 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import {
get_organization,
get_project,
@@ -313,7 +335,7 @@ import {
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
import { getServerAddress } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
@@ -326,6 +348,7 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -343,6 +366,10 @@ const messages = defineMessages({
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
},
switchVersion: {
id: 'app.project.install-button.switch-version',
defaultMessage: 'Switch version',
},
})
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@@ -514,6 +541,13 @@ const installButtonTooltip = computed(() => {
return null
})
const showSwitchVersion = computed(() => !!instance.value && installed.value)
const onVersionsPage = computed(() => route.name === 'Versions')
function goToVersions() {
router.push(versionsHref.value)
}
const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
@@ -600,10 +634,19 @@ async function fetchProjectData() {
function fetchDeferredServerData(project) {
const serverAddress = projectV3.value?.minecraft_java_server?.address
if (serverAddress) {
serverPing.value = undefined
getServerLatency(serverAddress)
.then((latency) => {
serverPing.value = latency
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
serverPing.value = cachedStatus.ping
serverStatusOnline.value = true
} else {
serverPing.value = undefined
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
serverPing.value = status.ping
serverStatusOnline.value = true
})
.catch((error) => {
console.error(`Failed to ping server ${serverAddress}:`, error)
+165 -429
View File
@@ -1,259 +1,152 @@
<template>
<div>
<Card>
<Breadcrumbs
:current-title="version.name"
:link-stack="[
{
href: buildProjectHref(`/project/${route.params.id}/versions`),
label: 'Versions',
},
]"
/>
<div class="version-title">
<h2>{{ version.name }}</h2>
</div>
<div class="button-group">
<div class="flex flex-col">
<router-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</router-link>
<VersionPage
v-if="version"
:version="version"
:enrichment="enrichment"
:enrichment-loading="enrichmentLoading"
:members="members"
:dependency-link-creator="createDependencyLink"
>
<template #headerActions>
<ButtonStyled color="brand">
<button
:disabled="installing || (installed && installedVersion === version.id)"
@click="() => install(version.id)"
@click="() => version && install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installedVersion !== version.id" />
<CheckIcon v-else />
{{
installing
? 'Installing...'
? formatMessage(messages.installing)
: installed && installedVersion === version.id
? 'Installed'
: 'Install'
? formatMessage(commonMessages.installedLabel)
: installed
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(commonMessages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button>
<ReportIcon />
Report
</button>
</ButtonStyled>
<ButtonStyled>
<a
:href="`https://modrinth.com/mod/${route.params.id}/version/${route.params.version}`"
rel="external"
<ButtonStyled type="outlined" circular>
<OverflowMenu
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
external: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
external: true,
},
]"
aria-label="More options"
>
Modrinth website
<ExternalIcon />
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template #supplementaryResourceActions="{ file }">
<ButtonStyled>
<a :href="file.url" :download="file.filename" target="_blank">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadInBrowser) }}
</a>
</ButtonStyled>
</div>
</Card>
<div class="version-container">
<div class="description-cards">
<Card>
<h3 class="card-title">Changelog</h3>
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
</Card>
<Card>
<h3 class="card-title">Files</h3>
<Card
v-for="file in version.files"
:key="file.id"
:class="{ primary: file.primary }"
class="file"
>
<span class="label">
<FileIcon />
<span>
<span class="title">
{{ file.filename }}
</span>
({{ formatBytes(file.size) }})
<span v-if="file.primary" class="primary-label"> Primary </span>
</span>
</span>
<ButtonStyled v-if="project.project_type !== 'modpack' || file.primary" color="brand">
<button class="download" :disabled="installed" @click="() => install(version.id)">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
</Card>
</Card>
<Card v-if="displayDependencies.length > 0">
<h2>Dependencies</h2>
<div v-for="dependency in displayDependencies" :key="dependency.title">
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
<Avatar size="sm" :src="dependency.icon" />
<div>
<span class="title"> {{ dependency.title }} </span> <br />
<span> {{ dependency.subtitle }} </span>
</div>
</router-link>
<div v-else class="dependency disabled" disabled="">
<Avatar size="sm" :src="dependency.icon" />
<div class="text">
<div class="title">{{ dependency.title }}</div>
<div>{{ dependency.subtitle }}</div>
</div>
</div>
</div>
</Card>
</div>
<Card class="metadata-card">
<h3 class="card-title">Metadata</h3>
<div class="metadata">
<div class="metadata-item">
<span class="metadata-label">Release Channel</span>
<span class="metadata-value"
><Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
"
/></span>
</div>
<div class="metadata-item">
<span class="metadata-label">Version Number</span>
<span class="metadata-value">{{ version.version_number }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Loaders</span>
<span class="metadata-value">{{
version.loaders
.map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1))
.join(', ')
}}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Game Versions</span>
<span class="metadata-value"> {{ version.game_versions.join(', ') }} </span>
</div>
<div class="metadata-item">
<span class="metadata-label">Downloads</span>
<span class="metadata-value">{{ version.downloads }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Publication Date</span>
<span class="metadata-value">
{{ formatDateTime(version.date_published) }}
</span>
</div>
<div v-if="author" class="metadata-item">
<span class="metadata-label">Author</span>
<a
:href="`https://modrinth.com/user/${author.user.username}`"
rel="external"
class="metadata-value btn author"
>
<Avatar size="sm" :src="author.user.avatar_url" circle />
<span>
<strong>
{{ author.user.username }}
</strong>
<br />
{{ author.role }}
</span>
</a>
</div>
<div class="metadata-item">
<span class="metadata-label">Version ID</span>
<span class="metadata-value"><CopyCode class="copycode" :text="version.id" /></span>
</div>
</div>
</Card>
</div>
</template>
</VersionPage>
</div>
</template>
<script setup>
import { CheckIcon, DownloadIcon, ExternalIcon, FileIcon, ReportIcon } from '@modrinth/assets'
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronLeftIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import {
Avatar,
Badge,
Breadcrumbs,
ButtonStyled,
Card,
CopyCode,
useFormatBytes,
useFormatDateTime,
commonMessages,
defineMessages,
type DependencyContext,
OverflowMenu,
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { releaseColor } from '@/helpers/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const { formatMessage } = useVIntl()
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
required: true,
},
members: {
type: Array,
required: true,
},
install: {
type: Function,
required: true,
},
installed: {
type: Boolean,
required: true,
const messages = defineMessages({
allVersions: {
id: 'app.project.version.all-versions',
defaultMessage: 'All versions',
},
installing: {
type: Boolean,
required: true,
id: 'app.project.version.installing',
defaultMessage: 'Installing',
},
installedVersion: {
type: String,
required: true,
downloadInBrowser: {
id: 'app.project.version.download-in-browser',
defaultMessage: 'Download in browser',
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps<{
project: Labrinth.Projects.v2.Project
versions: Labrinth.Versions.v3.Version[]
members: Labrinth.Projects.v3.TeamMember[]
install: (version: string | null) => void
installed: boolean
installing: boolean
installedVersion: string
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
breadcrumbs.setName('Version', version.value.name)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((version) => version.id === route.params.version)
await refreshDisplayDependencies()
breadcrumbs.setName('Version', version.value.name)
}
},
)
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
const author = computed(() =>
props.members ? props.members.find((member) => member.user.id === version.value.author_id) : null,
)
const displayDependencies = ref({})
function buildProjectHref(path) {
function buildProjectHref(path: string): string {
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)
for (const v of val) {
if (v != null) params.append(key, v)
}
} else if (val) {
params.append(key, String(val))
}
@@ -262,222 +155,65 @@ function buildProjectHref(path) {
return qs ? `${path}?${qs}` : path
}
async function refreshDisplayDependencies() {
const projectIds = new Set()
const versionIds = new Set()
if (version.value.dependencies) {
for (const dependency of version.value.dependencies) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
function createDependencyLink(context: DependencyContext): string | undefined {
if (context.version) {
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
}
const [projectDeps, versionDeps] = await Promise.all([
get_project_many([...projectIds]),
get_version_many([...versionIds]),
])
const dependencies = {
projects: projectDeps,
versions: versionDeps,
if (context.project) {
return buildProjectHref(`/project/${context.project.id}`)
}
displayDependencies.value = version.value.dependencies.map((dependency) => {
const version = dependencies.versions.find((obj) => obj.id === dependency.version_id)
if (version) {
const project = dependencies.projects.find(
(obj) => obj.id === version.project_id || obj.id === dependency.project_id,
)
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
}
} else {
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
if (project) {
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}`),
}
} else {
return {
icon: null,
title: dependency.file_name,
subtitle: `Added via overrides`,
link: null,
}
}
}
})
return undefined
}
await refreshDisplayDependencies()
async function refreshEnrichment() {
if (!version.value) return
const projectIds = new Set<string>()
const versionIds = new Set<string>()
for (const dependency of version.value.dependencies ?? []) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
if (projectIds.size === 0 && versionIds.size === 0) {
enrichment.value = { projects: [], versions: [] }
return
}
enrichmentLoading.value = true
try {
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
for (const dependencyVersion of versionResults ?? []) {
if (dependencyVersion.project_id) {
projectIds.add(dependencyVersion.project_id)
}
}
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
enrichment.value = {
projects: projectResults ?? [],
versions: versionResults ?? [],
}
} finally {
enrichmentLoading.value = false
}
}
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
)
await refreshEnrichment()
</script>
<style scoped lang="scss">
.version-container {
display: flex;
flex-direction: row;
gap: 1rem;
}
.version-title {
margin-bottom: 1rem;
h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--color-contrast);
margin: 0;
}
}
.dependency {
display: flex;
padding: 0.5rem 1rem 0.5rem 0.5rem;
gap: 0.5rem;
background: var(--color-raised-bg);
color: var(--color-base);
width: 100%;
.title {
font-weight: bolder;
}
:deep(svg) {
margin-right: 0 !important;
}
}
.file {
display: flex;
flex-direction: row;
gap: 0.5rem;
background: var(--color-button-bg);
color: var(--color-base);
padding: 0.5rem 1rem;
.download {
margin-left: auto;
background-color: var(--color-raised-bg);
}
.label {
display: flex;
margin: auto 0 auto;
gap: 0.5rem;
.title {
font-weight: bolder;
word-break: break-all;
}
svg {
min-width: 1.1rem;
min-height: 1.1rem;
width: 1.1rem;
height: 1.1rem;
margin: auto 0;
}
.primary-label {
font-style: italic;
}
}
}
.primary {
background: var(--color-brand-highlight);
color: var(--color-contrast);
}
.button-group {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
}
.card-title {
font-size: var(--font-size-lg);
color: var(--color-contrast);
margin: 0 0 0.5rem;
}
.description-cards {
width: 100%;
}
.metadata-card {
width: 20rem;
height: min-content;
}
.metadata {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
.metadata-label {
font-weight: bold;
}
}
}
.author {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
text-decoration: none;
color: var(--color-base);
background: var(--color-raised-bg);
padding: 0.5rem;
width: 100%;
box-shadow: none;
}
.markdown-body {
:deep(hr),
:deep(h1),
:deep(h2),
img {
max-width: max(60rem, 90%) !important;
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
.copycode {
border: 0;
color: var(--color-contrast);
}
.disabled {
display: flex;
flex-direction: row;
vertical-align: center;
align-items: center;
cursor: not-allowed;
border-radius: var(--radius-lg);
.text {
filter: brightness(0.5);
}
}
</style>
@@ -9,10 +9,18 @@
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent" color="green">
<ButtonStyled
circular
type="transparent"
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
>
<button
v-tooltip="
!installed ? 'Install' : version.id !== installedVersion ? 'Swap version' : ''
!installed
? formatMessage(commonMessages.installButton)
: version.id !== installedVersion
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(messages.alreadyInstalled)
"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
@@ -45,11 +53,13 @@
<DownloadIcon aria-hidden="true" />
Add to another instance
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
</OverflowMenu>
<a
v-else
v-tooltip="`Open in browser`"
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
@@ -65,9 +75,12 @@
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
@@ -76,8 +89,16 @@ import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useTheming } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
alreadyInstalled: {
id: 'app.project.versions.already-installed',
defaultMessage: 'Already installed',
},
})
defineProps({
project: {
type: Object,
@@ -257,7 +257,7 @@ const messages = defineMessages({
},
managePasskeyModalLoading: {
id: 'settings.account.security.passkey.modal.loading',
defaultMessage: 'Loading passkeys',
defaultMessage: 'Loading passkeys...',
},
managePasskeyModalNoPasskeys: {
id: 'settings.account.security.passkey.modal.no-passkeys',
@@ -2,12 +2,7 @@
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
@@ -17,7 +12,7 @@
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
:to="`/project/${projectRouteParam}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.published ||
props.queueEntry.project.updated,
)
})
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate())
})
const projectRouteParam = computed(
() => props.queueEntry.project.slug || props.queueEntry.project.id,
)
const formattedDate = computed(() => {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.published ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
function copyLink() {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
const projectUrl = `${base}/project/${projectRouteParam.value}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
@@ -10,9 +10,8 @@ import {
CodeIcon,
CopyIcon,
DownloadIcon,
EllipsisVerticalIcon,
ExternalIcon,
EyeOffIcon,
LinkIcon,
LoaderCircleIcon,
ScaleIcon,
ShieldCheckIcon,
@@ -113,51 +112,6 @@ const isProjectApproved = computed(() => {
)
})
const quickActions = computed<OverflowMenuOption[]>(() => {
const actions: OverflowMenuOption[] = []
const sourceUrl = props.item.project.link_urls?.['source']?.url
if (sourceUrl) {
actions.push({
id: 'view-source',
action: () => {
window.open(sourceUrl, '_blank', 'noopener,noreferrer')
},
})
}
actions.push(
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reportUrl = `${base}/moderation/technical-review/${props.item.project.id}`
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: 'success',
title: 'Technical Review link copied',
text: 'The link to this review has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.item.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
},
)
return actions
})
const isLoadingStatusAction = ref(false)
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
{
@@ -1020,6 +974,16 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
}
}
}
function copyId() {
navigator.clipboard.writeText(props.item.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
}
</script>
<template>
@@ -1105,25 +1069,31 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<div class="flex items-center gap-3">
<span class="text-base text-secondary">{{ formattedDate }}</span>
<ButtonStyled circular>
<OverflowMenu :options="quickActions" class="!shadow-none">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
<template #view-source>
<div class="flex items-center gap-2">
<ButtonStyled v-if="props.item.project.link_urls?.['source']?.url" circular>
<a
v-tooltip="'Open sources in new tab'"
:href="props.item.project.link_urls?.['source']?.url"
target="_blank"
>
<CodeIcon />
<span class="hidden sm:inline">View source</span>
</template>
</OverflowMenu>
</ButtonStyled>
</a>
</ButtonStyled>
<ButtonStyled circular>
<button v-tooltip="'Copy ID'" @click="copyId">
<ClipboardCopyIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Open in new tab'"
:href="`/moderation/technical-review/${props.item.project.id}`"
target="_blank"
>
<ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</div>
@@ -0,0 +1,326 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FolderSearchIcon, StarIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectModrinthClient,
NewModal,
Table,
type TableColumn,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
const messages = defineMessages({
title: {
id: 'modpack-scan-modal.title',
defaultMessage: 'Modpack Scan ({scanned}/{total} Files)',
},
scanAllFiles: {
id: 'modpack-scan-modal.scan-all-files',
defaultMessage: 'Scan All Files',
},
packFileName: {
id: 'modpack-scan-modal.pack-file-name',
defaultMessage: 'Pack File Name',
},
newFiles: {
id: 'modpack-scan-modal.new-files',
defaultMessage: 'New Files',
},
newGroups: {
id: 'modpack-scan-modal.new-groups',
defaultMessage: 'New Groups',
},
loadingVersions: {
id: 'modpack-scan-modal.loading-versions',
defaultMessage: 'Loading versions...',
},
noFiles: {
id: 'modpack-scan-modal.no-files',
defaultMessage: 'No files found.',
},
notScanned: {
id: 'modpack-scan-modal.not-scanned',
defaultMessage: 'Not scanned',
},
scanning: {
id: 'modpack-scan-modal.scanning',
defaultMessage: 'Scanning...',
},
failed: {
id: 'modpack-scan-modal.failed',
defaultMessage: 'Failed',
},
overrideFiles: {
id: 'modpack-scan-modal.override-files',
defaultMessage: 'Override Files ({count})',
},
loadVersionsError: {
id: 'modpack-scan-modal.load-versions-error',
defaultMessage: 'Failed to load versions: {error}',
},
scanError: {
id: 'modpack-scan-modal.scan-error',
defaultMessage: 'Some files failed to scan: {error}',
},
})
type ScanTableColumn = 'filename' | 'newFiles' | 'newGroups'
type ScanRow = {
id: string
filename: string
primary: boolean
scan?: Labrinth.Attribution.Internal.FileScanResponse
isScanning: boolean
error?: string
newFiles?: number
newGroups?: number
}
const props = defineProps<{
project_id: string
}>()
const client = injectModrinthClient()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const { formatMessage } = useVIntl()
const rows = ref<ScanRow[]>([])
const isLoadingVersions = ref(false)
const isScanning = ref(false)
const versionLoadError = ref<string | null>(null)
const scanError = ref<string | null>(null)
const requestId = ref(0)
const scanRequestId = ref(0)
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
])
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
const isBusy = computed(() => isLoadingVersions.value || isScanning.value)
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'object' && error !== null && 'data' in error) {
const data = (error as { data?: { description?: string } }).data
if (data?.description) {
return data.description
}
}
return String(error)
}
async function fetchAllVersions() {
const currentRequestId = ++requestId.value
isLoadingVersions.value = true
versionLoadError.value = null
scanError.value = null
rows.value = []
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
if (currentRequestId !== requestId.value) {
return
}
rows.value = versions
.flatMap((version) => version.files)
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
.map((file) => ({
id: file.id,
filename: file.filename,
primary: file.primary,
isScanning: false,
newFiles: undefined,
newGroups: undefined,
}))
} catch (error) {
if (currentRequestId === requestId.value) {
versionLoadError.value = formatMessage(messages.loadVersionsError, {
error: getErrorMessage(error),
})
}
} finally {
if (currentRequestId === requestId.value) {
isLoadingVersions.value = false
}
}
}
async function fetchAllScans() {
if (isBusy.value) {
return
}
const currentScanRequestId = ++scanRequestId.value
isScanning.value = true
scanError.value = null
rows.value = rows.value.map((row) => ({
...row,
scan: undefined,
isScanning: false,
error: undefined,
newFiles: undefined,
newGroups: undefined,
}))
try {
for (const row of rows.value) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.isScanning = true
try {
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.scan = scan
row.newFiles = scan.new_attribution_files
row.newGroups = scan.new_attribution_groups
} catch (error) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.error = getErrorMessage(error)
scanError.value = formatMessage(messages.scanError, { error: row.error })
} finally {
row.isScanning = false
}
}
} finally {
if (currentScanRequestId === scanRequestId.value) {
isScanning.value = false
}
}
}
function show() {
scanRequestId.value++
isScanning.value = false
rows.value = []
void fetchAllVersions()
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modalRef"
width="60vw"
:close-on-click-outside="false"
:close-on-esc="false"
:disable-close="isBusy"
>
<template #title>
<div class="flex w-full items-center justify-between gap-2">
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
scanned: scannedCount,
total: rows.length,
})
}}
</span>
<div>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.scanAllFiles)"
:disabled="isBusy || rows.length === 0"
@click="fetchAllScans"
>
<FolderSearchIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<div class="w-full">
<div
v-if="versionLoadError || scanError"
class="mb-3 rounded-xl bg-highlight-red p-3 text-red"
>
{{ versionLoadError || scanError }}
</div>
<Table
:columns="columns"
:data="rows"
row-key="id"
:row-below-visible="
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
"
table-min-width="42rem"
>
<template #cell-filename="{ row }">
<div class="flex min-w-0 items-center gap-1 text-contrast">
<StarIcon v-if="row.primary" class="size-4 shrink-0" aria-hidden="true" />
<span class="min-w-0 truncate">{{ row.filename }}</span>
</div>
</template>
<template #cell-newFiles="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
{{ formatMessage(messages.failed) }}
</span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
</template>
<template #cell-newGroups="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
{{ formatMessage(messages.failed) }}
</span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
</template>
<template #row-below="{ row }">
<div class="border-0 border-t border-solid border-surface-4 px-4 py-3">
<details>
<summary>
{{
formatMessage(messages.overrideFiles, {
count: row.scan?.scanned_file_names.length ?? 0,
})
}}
</summary>
<div class="flex flex-wrap gap-1 pt-2">
<span
v-for="name of row.scan?.scanned_file_names ?? []"
:key="name"
v-tooltip="name"
class="flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium text-contrast"
>
{{ name }}
</span>
</div>
</details>
</div>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
{{ formatMessage(isLoadingVersions ? messages.loadingVersions : messages.noFiles) }}
</div>
</template>
</Table>
</div>
</NewModal>
</template>
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
const refreshes: Promise<unknown>[] = [
invalidate(),
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
@@ -22,6 +22,8 @@ import {
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useVIntl,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
@@ -604,11 +606,15 @@ export function useServerInstallContent({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(browseSearchState.currentFilters.value),
providedFilters: isModpack ? [] : serverFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: browseSearchState.overriddenProvidedFilterTypes.value,
: stripServerRuntimeInstallOverrides(
browseSearchState.overriddenProvidedFilterTypes.value,
),
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
+3 -6
View File
@@ -1,3 +1,4 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
import type {
Organization,
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
export interface ModerationProject {
project: any
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
ownership: ModerationOwnership | null
external_dependencies_count: number
}
+43 -1
View File
@@ -2672,6 +2672,9 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
"moderation.moderate": {
"message": "Moderate"
},
@@ -2687,6 +2690,45 @@
"moderation.page.technicalReview": {
"message": "Tech review"
},
"modpack-scan-modal.failed": {
"message": "Failed"
},
"modpack-scan-modal.load-versions-error": {
"message": "Failed to load versions: {error}"
},
"modpack-scan-modal.loading-versions": {
"message": "Loading versions..."
},
"modpack-scan-modal.new-files": {
"message": "New Files"
},
"modpack-scan-modal.new-groups": {
"message": "New Groups"
},
"modpack-scan-modal.no-files": {
"message": "No files found."
},
"modpack-scan-modal.not-scanned": {
"message": "Not scanned"
},
"modpack-scan-modal.override-files": {
"message": "Override Files ({count})"
},
"modpack-scan-modal.pack-file-name": {
"message": "Pack File Name"
},
"modpack-scan-modal.scan-all-files": {
"message": "Scan All Files"
},
"modpack-scan-modal.scan-error": {
"message": "Some files failed to scan: {error}"
},
"modpack-scan-modal.scanning": {
"message": "Scanning..."
},
"modpack-scan-modal.title": {
"message": "Modpack Scan ({scanned}/{total} Files)"
},
"muralpay.account-type.checking": {
"message": "Checking"
},
@@ -4164,7 +4206,7 @@
"message": "Last used {ago}"
},
"settings.account.security.passkey.modal.loading": {
"message": "Loading passkeys"
"message": "Loading passkeys..."
},
"settings.account.security.passkey.modal.never-used": {
"message": "Never used"
+24 -5
View File
@@ -1,6 +1,7 @@
import { useGeneratedState } from '~/composables/generated'
import { projectQueryOptions } from '~/composables/queries/project'
import { useAppQueryClient } from '~/composables/query-client'
import { createModrinthClient } from '~/helpers/api.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
@@ -18,9 +19,6 @@ const PROJECT_TYPES = [
]
export default defineNuxtRouteMiddleware(async (to) => {
// Only run this middleware on the server - it relies on server-only runtime config
if (import.meta.client) return
const routeProjectParam = to.params.project
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
@@ -31,10 +29,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
const queryClient = useAppQueryClient()
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const client = await getProjectMiddlewareClient()
const tags = useGeneratedState()
if (import.meta.client) startLoading()
try {
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
const [project, projectV3] = await Promise.all([
@@ -48,9 +47,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Cache by slug if we looked up by ID (or vice versa)
if (projectId !== project.slug) {
queryClient.setQueryData(['project', 'v2', project.slug], project)
queryClient.setQueryData(['project', 'v3', project.slug], projectV3)
}
if (projectId !== project.id) {
queryClient.setQueryData(['project', 'v2', project.id], project)
queryClient.setQueryData(['project', 'v3', project.id], projectV3)
}
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
@@ -81,5 +82,23 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
} catch {
// Let the page handle 404s and other errors
} finally {
if (import.meta.client) stopLoading()
}
})
async function getProjectMiddlewareClient() {
if (import.meta.server) {
const authToken = useCookie('auth-token')
return useServerModrinthClient({ authToken: authToken.value || undefined })
}
const auth = await useAuth()
const config = useRuntimeConfig()
return createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
rateLimitKey: config.rateLimitKey,
})
}
+18 -7
View File
@@ -95,6 +95,8 @@
@download="triggerDownloadAnimation"
/>
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
<ModpackScanModal ref="scanModal" :project_id="project.id" />
<div
class="new-page sidebar"
:class="{
@@ -424,13 +426,6 @@
tags.staffRoles.includes(auth.user.role) &&
!showModerationChecklist,
},
{
divider: true,
shown:
auth.user &&
tags.staffRoles.includes(auth.user.role) &&
!showModerationChecklist,
},
{
id: 'tech-review',
link: `/moderation/technical-review/${project.id}`,
@@ -438,6 +433,16 @@
hoverOnly: true,
shown: auth.user && tags.staffRoles.includes(auth.user.role),
},
{
id: 'moderation-modpack-rescan',
action: () => scanModal.show(),
color: 'orange',
hoverOnly: true,
shown:
auth.user &&
tags.staffRoles.includes(auth.user.role) &&
project.actualProjectType === 'modpack',
},
{
divider: true,
shown: auth.user && tags.staffRoles.includes(auth.user.role),
@@ -469,6 +474,9 @@
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
</template>
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
<template #moderation-modpack-rescan>
<FolderSearchIcon aria-hidden="true" /> Rescan modpack
</template>
<template #report>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
@@ -726,6 +734,7 @@ import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
FolderSearchIcon,
HeartIcon,
ListIcon,
MoreVerticalIcon,
@@ -785,6 +794,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
@@ -852,6 +862,7 @@ const debug = useDebugLogger('DownloadModal')
const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const scanModal = ref()
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
+1 -1
View File
@@ -143,7 +143,7 @@ onMounted(() => {
{{ previewError }}
</div>
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview</div>
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview...</div>
<div v-else>
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
<label
+167 -188
View File
@@ -18,6 +18,7 @@
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-class="!h-10"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
@@ -26,7 +27,7 @@
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
>{{ currentFilterType }} ({{ totalProjects }})</span
>
</span>
</template>
@@ -35,6 +36,7 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-class="!h-10"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -54,6 +56,7 @@
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-class="!h-10"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
@@ -69,7 +72,7 @@
<ButtonStyled color="orange">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
:disabled="paginatedProjects?.length === 0"
:disabled="pending || paginatedProjects?.length === 0"
@click="moderateAllInFilter()"
>
<ScaleIcon class="flex-shrink-0" />
@@ -80,17 +83,27 @@
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
}}
of {{ filteredProjects.length }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div v-if="totalProjects > 0">
Showing {{ pageStart }}{{ pageEnd }} of {{ totalProjects }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<div class="flex items-center gap-2 text-sm font-semibold text-secondary">
<Toggle id="moderation-exclude-technical-review" v-model="excludeTechnicalReview" small />
<label class="cursor-pointer" for="moderation-exclude-technical-review">
{{ formatMessage(messages.excludeTechnicalReview) }}
</label>
</div>
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<Pagination
v-if="totalPages > 1"
:page="currentPage"
:count="totalPages"
@switch-page="goToPage"
/>
<ConfettiExplosion v-if="visible" />
</div>
@@ -124,6 +137,7 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -132,20 +146,18 @@ import {
commonMessages,
defineMessages,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js'
import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import {
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
@@ -173,37 +186,14 @@ const messages = defineMessages({
id: 'moderation.moderate',
defaultMessage: 'Moderate',
},
})
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
let projects: ProjectWithOwnership[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as ProjectWithOwnership[]
if (projects.length === 0) break
allProjects.push(...toModerationProjects(projects))
currentOffset += projects.length
} while (projects.length === PROJECT_ENDPOINT_COUNT)
const duration = performance.now() - startTime
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
return allProjects
excludeTechnicalReview: {
id: 'moderation.exclude-technical-review',
defaultMessage: 'Exclude TR',
},
})
const query = ref(route.query.q?.toString() || '')
const excludeTechnicalReview = ref(false)
watch(
query,
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
})
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
function toApiProjectType(label: string): string | undefined {
switch (label) {
case 'Modpacks':
return 'modpack'
case 'Mods':
return 'mod'
case 'Resource Packs':
return 'resourcepack'
case 'Data Packs':
return 'datapack'
case 'Plugins':
return 'plugin'
case 'Shaders':
return 'shader'
case 'Servers':
return 'minecraft_java_server'
case 'Fucked up':
return 'none'
default:
return undefined
}
}
function toApiSort(label: string): Labrinth.Moderation.Internal.ProjectsSort {
switch (label) {
case 'Newest':
return 'newest'
case 'Most external deps':
return 'most_external_deps'
case 'Least external deps':
return 'least_external_deps'
default:
return 'oldest'
}
}
const moderationProjectsRequest = computed<Labrinth.Moderation.Internal.ProjectsRequest>(() => ({
count: itemsPerPage.value,
offset: (currentPage.value - 1) * itemsPerPage.value,
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
}))
const moderationProjectsQueryKey = computed(
() => ['moderation-projects', moderationProjectsRequest.value] as const,
)
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null
return new Fuse(allProjects.value, {
keys: [
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 2,
},
{
name: 'project.description',
weight: 2,
},
{
name: 'project.project_type',
weight: 1,
},
'ownership.name',
],
includeScore: true,
threshold: 0.4,
})
const {
data: moderationProjectsResponse,
isPending: moderationProjectsPending,
isPlaceholderData: moderationProjectsPlaceholder,
} = useQuery({
queryKey: moderationProjectsQueryKey,
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
placeholderData: (previousData) => previousData,
})
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
if (!allProjects.value) return []
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All projects') {
return baseFiltered.value
} else if (currentFilterType.value === 'Fucked up') {
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
const pending = computed(
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
)
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
const filteredProjects = computed(() =>
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
)
const paginatedProjects = computed(() => filteredProjects.value)
const pageStart = computed(() =>
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
)
const pageEnd = computed(() =>
Math.min(
(currentPage.value - 1) * itemsPerPage.value + paginatedProjects.value.length,
totalProjects.value,
),
)
const projectsById = computed(() => {
const projects = new Map<string, ModerationProject>()
for (const project of filteredProjects.value) {
projects.set(project.project.id, project)
}
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
Mods: 'mod',
'Resource Packs': 'resourcepack',
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
Servers: 'minecraft_java_server',
}
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
(queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
)
return projects
})
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Most external deps') {
filtered.sort((a, b) => {
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Least external deps') {
filtered.sort((a, b) => {
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateB - dateA
})
watch(totalPages, (pages) => {
if (pages === 0 && currentPage.value !== 1) {
currentPage.value = 1
return
}
return filtered
if (pages > 0 && currentPage.value > pages) {
currentPage.value = pages
}
})
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
watch(excludeTechnicalReview, () => {
goToPage(1)
})
const emptyStateHeading = computed(() => {
@@ -525,21 +505,16 @@ function notifySkippedProjects(skippedCount: number) {
})
}
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
async function findFirstEligibleProject(): Promise<string | null> {
let skippedCount = 0
while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
const project = projectsById.value.get(currentId)
if (project.project.status !== 'processing') {
if (project && project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
notifySkippedProjects(skippedCount)
return project
return currentId
}
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return project
return currentId
}
}
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
return null
}
function getProjectRouteParam(projectId: string): string {
return projectsById.value.get(projectId)?.project.slug || projectId
}
async function navigateToModerationProject(projectId: string) {
await navigateTo({
name: 'type-project',
params: {
type: 'project',
project: getProjectRouteParam(projectId),
},
state: {
showChecklist: true,
},
})
}
async function getFilteredProjectIds(): Promise<string[]> {
const response = await client.labrinth.moderation_internal.getProjectIds({
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
})
return response.ids
}
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
const projectIds = (await getFilteredProjectIds()).slice(startIndex)
await moderationQueue.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstEligibleProject()
const targetProjectId = await findFirstEligibleProject()
if (!targetProject) {
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
await navigateToModerationProject(targetProjectId)
}
async function startFromProject(projectId: string) {
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
await moderationQueue.setSingleProject(projectId)
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
const projectIds = allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
}
const targetProject = await findFirstEligibleProject()
const targetProjectId = await findFirstEligibleProject()
if (!targetProject) {
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
await navigateToModerationProject(targetProjectId)
}
</script>
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n select id\n from mods\n where id = any($1)\n and status not in ('rejected', 'draft', 'withheld', 'withdrawn')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd"
}
@@ -1,31 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "external_dependencies_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Bool"
]
},
"nullable": [
false,
null
]
},
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM mods\n WHERE\n status = $1\n AND (\n $3::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = mods.id\n AND didws.status = 'pending'\n )\n )\n ORDER BY\n CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,6 +78,7 @@ pub enum LegacyNotificationBody {
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
invited_by: UserId,
},
StatusChange {
project_id: ProjectId,
@@ -306,9 +307,11 @@ impl LegacyNotification {
NotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
invited_by,
} => LegacyNotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
invited_by,
},
NotificationBody::StatusChange {
project_id,
@@ -179,6 +179,7 @@ pub enum NotificationBody {
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
invited_by: UserId,
},
StatusChange {
project_id: ProjectId,
+43 -7
View File
@@ -542,6 +542,26 @@ const OVERRIDE_PREFIXES: &[&str] = &[
"client-overrides/resourcepacks",
];
const OVERRIDE_ROOT_PREFIXES: &[&str] =
&["overrides/", "client-overrides/", "server-overrides/"];
fn override_relative_name(name: &str) -> Option<&str> {
// strip the root prefix
let relative = OVERRIDE_ROOT_PREFIXES
.iter()
.find_map(|prefix| name.strip_prefix(prefix))?;
// check if it matches any of the whitelisted scan prefixes
OVERRIDE_PREFIXES
.iter()
.any(|prefix| {
name.strip_prefix(prefix)
// check the stripped prefix is actually a full segment, not something weird like "overrides/modsabce/file.jar"
.is_some_and(|suffix| suffix.starts_with('/'))
})
.then_some(relative)
}
fn should_scan(name: &str) -> bool {
let name = name.to_lowercase();
let should_skip = name.starts_with("mods/.connector/")
@@ -550,7 +570,7 @@ fn should_scan(name: &str) -> bool {
|| name.starts_with("mods/mcef-libraries/")
|| name.starts_with("mods/mcef-cache/")
|| name.starts_with("config/super_resolution/libraries/")
|| name.starts_with("config/Veinminer/update/")
|| name.starts_with("config/veinminer/update/")
|| name.starts_with("config/epicfight/native/")
|| name.starts_with("essential/")
|| name.ends_with(".rpo")
@@ -579,14 +599,11 @@ fn extract_override_files(data: &[u8]) -> Result<Vec<OverrideFile>> {
continue;
}
if !OVERRIDE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
{
let Some(relative_name) = override_relative_name(&name) else {
continue;
}
};
if !should_scan(&name) {
if !should_scan(relative_name) {
continue;
}
@@ -941,12 +958,31 @@ async fn resolve_overrides(
.await
.wrap_err("fetching files on platform by hash")?;
let matching_project_ids: Vec<_> =
files.iter().map(|file| file.project_id.0).collect();
let valid_project_ids = sqlx::query_scalar!(
r#"
select id
from mods
where id = any($1)
and status not in ('rejected', 'draft', 'withheld', 'withdrawn')
"#,
&matching_project_ids,
)
.fetch_all(&mut *txn)
.await
.wrap_err("fetching matched file project statuses")?;
let version_ids: Vec<_> = files.iter().map(|x| x.version_id).collect();
let versions_data = DBVersion::get_many(&version_ids, &mut *txn, redis)
.await
.wrap_err("fetching versions")?;
for file in &files {
if !valid_project_ids.contains(&file.project_id.0) {
continue;
}
if !versions_data.iter().any(|v| v.inner.id == file.version_id) {
continue;
}
+12 -5
View File
@@ -1,9 +1,8 @@
use actix_web::{HttpResponse, get};
use serde_json::json;
#[get("/")]
pub async fn index_get() -> HttpResponse {
let data = json!({
fn build_info() -> serde_json::Value {
json!({
"name": "modrinth-labrinth",
"version": env!("CARGO_PKG_VERSION"),
"documentation": "https://docs.modrinth.com",
@@ -14,7 +13,15 @@ pub async fn index_get() -> HttpResponse {
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown"),
"profile": env!("COMPILATION_PROFILE"),
}
});
})
}
HttpResponse::Ok().json(data)
#[get("/")]
pub async fn index_get() -> HttpResponse {
HttpResponse::Ok().json(build_info())
}
#[get("/build")]
pub async fn build_get() -> HttpResponse {
HttpResponse::Ok().json(build_info())
}
+1
View File
@@ -101,6 +101,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
pats::edit_pat,
pats::delete_pat,
moderation::get_projects,
moderation::get_project_ids,
moderation::get_project_meta,
moderation::set_project_meta,
moderation::acquire_lock,
File diff suppressed because it is too large Load Diff
+1
View File
@@ -138,6 +138,7 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
web::scope("")
.wrap(default_cors())
.service(index::index_get)
.service(index::build_get)
.service(Files::new("/", "assets/")),
);
}
-2
View File
@@ -1,4 +1,3 @@
pub(crate) mod moderation;
mod notifications;
mod openapi;
pub(crate) mod project_creation;
@@ -26,7 +25,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(super::internal::flows::config)
.configure(super::internal::pats::config)
.configure(super::internal::admin::config)
.configure(moderation::config)
.configure(notifications::config)
.configure(project_creation::config)
.configure(projects::config)
-79
View File
@@ -1,79 +0,0 @@
use super::ApiError;
use crate::database::PgPool;
use crate::models::projects::Project;
use crate::models::v2::projects::LegacyProject;
use crate::queue::session::AuthQueue;
use crate::routes::internal;
use crate::{database::redis::RedisPool, routes::v2_reroute};
use actix_web::{HttpRequest, HttpResponse, get, web};
use serde::Deserialize;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/moderation").service(get_projects));
}
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
pub count: u16,
}
fn default_count() -> u16 {
100
}
/// List projects in the moderation queue.
#[utoipa::path(
context_path = "/moderation",
tag = "v2 moderation",
get,
operation_id = "getModerationProjects",
params(
("count" = Option<u16>, Query, description = "Maximum number of projects to return")
),
responses(
(status = 200, description = "Expected response to a valid request", body = Vec<LegacyProject>),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_READ"]))
)]
#[get("/projects")]
pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ResultCount>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let response = internal::moderation::get_projects_internal(
req,
pool.clone(),
redis.clone(),
web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
has_external_dependencies: None,
}),
session_queue,
)
.await
.map(|resp| HttpResponse::Ok().json(resp))
.or_else(v2_reroute::flatten_404_error)?;
// Convert to V2 projects
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis).await?;
Ok(HttpResponse::Ok().json(legacy_projects))
}
Err(response) => Ok(response),
}
}
+2 -2
View File
@@ -28,10 +28,10 @@ const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v1";
const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/v3").service(resolve_content));
cfg.service(resolve_content);
}
/// Resolve content.
/// Resolve content.
#[utoipa::path(
tag = "content",
request_body = serde_json::Value,
+2 -2
View File
@@ -69,9 +69,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(users::config)
.configure(version_file::config)
.configure(versions::config)
.configure(friends::config),
.configure(friends::config)
.configure(content::config),
);
cfg.configure(content::config);
}
#[derive(utoipa::OpenApi)]
@@ -100,4 +100,21 @@ export class LabrinthAttributionInternalModule extends AbstractModule {
body,
})
}
/**
* Scan a file for attribution information.
* POST /_internal/attribution/file/{file_id}/scan
*
* @param fileId - The file ID to scan.
*/
public async scanFile(fileId: string): Promise<Labrinth.Attribution.Internal.FileScanResponse> {
return this.client.request<Labrinth.Attribution.Internal.FileScanResponse>(
`/attribution/file/${fileId}/scan`,
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
}
@@ -6,6 +6,34 @@ export class LabrinthModerationInternalModule extends AbstractModule {
return 'labrinth_moderation_internal'
}
public async getProjects(
params: Labrinth.Moderation.Internal.ProjectsRequest = {},
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectsResponse>(
'/moderation/projects',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async getProjectIds(
params: Omit<Labrinth.Moderation.Internal.ProjectsRequest, 'count' | 'offset'> = {},
): Promise<Labrinth.Moderation.Internal.ProjectIdsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectIdsResponse>(
'/moderation/projects/ids',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async acquireLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
@@ -397,6 +397,12 @@ export namespace Labrinth {
sha1: string
project_id: string
}
export type FileScanResponse = {
new_attribution_groups: number
new_attribution_files: number
scanned_file_names: string[]
}
}
}
@@ -1333,6 +1339,7 @@ export namespace Labrinth {
}
export type VersionFile = {
id?: string
hashes: VersionFileHash
url: string
filename: string
@@ -1454,6 +1461,7 @@ export namespace Labrinth {
}
export interface VersionFile {
id?: string
hashes: VersionFileHash
url: string
filename: string
@@ -1906,6 +1914,57 @@ export namespace Labrinth {
export namespace Moderation {
export namespace Internal {
export type Ownership =
| {
kind: 'user'
id: string
name: string
icon_url: string | null
}
| {
kind: 'organization'
id: string
name: string
icon_url: string | null
}
export type ProjectsSort = 'oldest' | 'newest' | 'most_external_deps' | 'least_external_deps'
export type ProjectsRequest = {
count?: number
offset?: number
has_external_dependencies?: boolean
exclude_technical_review?: boolean
query?: string
project_type?: string
sort?: ProjectsSort
}
export type QueueProject = {
id: string
slug: string | null
name: string
summary: string
icon_url: string | null
status: Projects.v2.ProjectStatus
requested_status: Projects.v2.ProjectStatus | null
queued: string | null
published: string
updated: string
project_types: string[]
ownership: Ownership
external_dependencies_count: number
}
export type ProjectsResponse = {
total: number
projects: QueueProject[]
}
export type ProjectIdsResponse = {
ids: string[]
}
export type LockedByUser = {
id: string
username: string
+17
View File
@@ -10,6 +10,23 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-06T22:19:13+00:00`,
product: 'app',
version: '0.15.8',
body: `## Changed
- Updated the version pages to use the new design.
## Fixed
- Fixed project and version links from an instance not being aware of the instance you're coming from.
- Fixed Files tab preloading files which weren't actually editable/viewable which caused a memory leak.`,
},
{
date: `2026-07-06T22:19:13+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed Files tab preloading files which weren't actually editable/viewable which caused a memory leak.`,
},
{
date: `2026-07-05T01:48:48+00:00`,
product: 'app',
@@ -38,7 +38,7 @@ const messages = defineMessages({
},
addFilesModalSearchPlaceholder: {
id: 'external-files.permissions-card.add-files-modal.search-placeholder',
defaultMessage: 'Search files',
defaultMessage: 'Search files...',
},
addFilesModalNoSearchResults: {
id: 'external-files.permissions-card.add-files-modal.no-search-results',
@@ -216,7 +216,7 @@ defineExpose({ show, hide })
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search external projects"
placeholder="Search external projects..."
clearable
wrapper-class="flex-1 min-w-[12rem]"
:disabled="addFilesMutation.isPending.value"
@@ -237,14 +237,14 @@ defineExpose({ show, hide })
>
<span class="text-contrast font-semibold">
<template v-if="searchNeedsInput"> Enter a search term to get started </template>
<template v-else-if="isLoading"> Loading external projects </template>
<template v-else-if="isLoading"> Loading external projects...</template>
<template v-else> No projects matched that search </template>
</span>
<span class="text-secondary text-sm">
<template v-if="searchNeedsInput">
Type at least 3 characters of a project's title to begin browsing.
</template>
<template v-else-if="isLoading"> Loading external projects </template>
<template v-else-if="isLoading"> Loading external projects...</template>
<template v-else> No projects matched that search </template>
</span>
</div>
@@ -384,9 +384,26 @@ export function getLoaderFilterTypes(contentType: string) {
if (contentType === 'plugin') return ['plugin_loader', 'plugin_platform']
if (contentType === 'modpack') return ['modpack_loader']
if (contentType === 'shader') return ['shader_loader']
if (contentType === 'datapack') return ['datapack_loader']
return []
}
const SERVER_RUNTIME_INSTALL_FILTER_TYPES = new Set([
'game_version',
'mod_loader',
'plugin_loader',
'plugin_platform',
'datapack_loader',
])
export function stripServerRuntimeInstallFilters(filters: readonly FilterValue[]) {
return filters.filter((filter) => !SERVER_RUNTIME_INSTALL_FILTER_TYPES.has(filter.type))
}
export function stripServerRuntimeInstallOverrides(filterTypes: readonly string[]) {
return filterTypes.filter((type) => !SERVER_RUNTIME_INSTALL_FILTER_TYPES.has(type))
}
/**
* Merges user-selected filters with target-provided filters for install decisions.
*
@@ -454,7 +471,12 @@ export function getTargetInstallPreferences(
return normalizeInstallPreferences({
gameVersions: gameVersion && shouldUseTargetRuntime ? [gameVersion] : undefined,
loaders: loader && shouldUseTargetRuntime ? [loader] : undefined,
loaders:
contentType === 'datapack'
? ['datapack']
: loader && shouldUseTargetRuntime
? [loader]
: undefined,
})
}
@@ -515,10 +537,9 @@ export function mergeInstallPreferences(
export function getLatestMatchingInstallVersion(
versions: readonly Labrinth.Versions.v2.Version[],
preferences: BrowseInstallPreferences,
contentType: string,
) {
return [...versions]
.filter((version) => versionMatchesPreferences(version, preferences, contentType))
.filter((version) => versionMatchesPreferences(version, preferences))
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())[0]
}
@@ -543,11 +564,7 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
let lastError: Error | null = null
for (const candidate of candidates) {
const version = getLatestMatchingInstallVersion(
versions,
candidate.preferences,
options.contentType,
)
const version = getLatestMatchingInstallVersion(versions, candidate.preferences)
if (version) {
const fileName =
@@ -747,13 +764,11 @@ function hasPreferences(preferences: BrowseInstallPreferences) {
function versionMatchesPreferences(
version: Labrinth.Versions.v2.Version,
preferences: BrowseInstallPreferences,
contentType: string,
) {
const gameVersionMatches =
!preferences.gameVersions?.length ||
version.game_versions.some((gameVersion) => preferences.gameVersions?.includes(gameVersion))
if (!gameVersionMatches) return false
if (contentType === 'datapack') return true
if (!preferences.loaders?.length) return true
const compatibleLoaders = getCompatibleLoaderAliasSet(preferences.loaders)
@@ -18,7 +18,7 @@ import {
TextCursorInputIcon,
TrashIcon,
} from '@modrinth/assets'
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
@@ -389,10 +389,11 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
showDeletionConfirmation(event)
}
function showDeletionConfirmation(event?: MouseEvent) {
async function showDeletionConfirmation(event?: MouseEvent) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
confirmDelete()
} else {
await nextTick()
confirmDeletionModal.value?.show()
}
}
@@ -118,11 +118,7 @@ import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectNotificationManager } from '#ui/providers/web-notifications'
import { getFileExtensionIcon } from '#ui/utils/auto-icons'
import { commonMessages } from '#ui/utils/common-messages'
import {
getFileExtension,
isEditableFile as isEditableFileExt,
isImageFile,
} from '#ui/utils/file-extensions'
import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
import {
fileDragActive,
@@ -303,8 +299,7 @@ const formattedCreationDate = computed(() => {
const isEditableFile = computed(() => {
if (props.type === 'file') {
const ext = fileExtension.value
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
return canOpenInFileEditor(props.name)
}
return false
})
@@ -225,7 +225,7 @@ import { useVirtualScroll } from '#ui/composables/virtual-scroll'
import { injectFilePicker } from '#ui/providers/file-picker'
import { injectNotificationManager } from '#ui/providers/web-notifications'
import { commonMessages } from '#ui/utils/common-messages'
import { getFileExtension } from '#ui/utils/file-extensions'
import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
import FileEditor from './components/editor/FileEditor.vue'
import FileContextMenu from './components/FileContextMenu.vue'
@@ -650,7 +650,7 @@ function handleItemHover(item: { type: string; path: string; name: string }) {
: `${currentPath}/${item.name}`
ctx.prefetchDirectory?.(navPath)
}, 150)
} else {
} else if (canOpenInFileEditor(item.name)) {
prefetchTimeout = setTimeout(() => {
ctx.prefetchFile?.(item.path)
}, 150)
+7 -1
View File
@@ -221,6 +221,9 @@
"button.open-folder": {
"defaultMessage": "Open folder"
},
"button.open-in-browser": {
"defaultMessage": "Open in browser"
},
"button.open-in-folder": {
"defaultMessage": "Open in folder"
},
@@ -290,6 +293,9 @@
"button.stop": {
"defaultMessage": "Stop"
},
"button.switch-to-version": {
"defaultMessage": "Switch to version"
},
"button.switch-version": {
"defaultMessage": "Switch version"
},
@@ -912,7 +918,7 @@
"defaultMessage": "No files match your search."
},
"external-files.permissions-card.add-files-modal.search-placeholder": {
"defaultMessage": "Search files"
"defaultMessage": "Search files..."
},
"external-files.permissions-card.add-files-modal.selected-count": {
"defaultMessage": "{count, plural, one {# file selected} other {# files selected}}"
+8
View File
@@ -207,6 +207,10 @@ export const commonMessages = defineMessages({
id: 'button.open-folder',
defaultMessage: 'Open folder',
},
openInBrowserButton: {
id: 'button.open-in-browser',
defaultMessage: 'Open in browser',
},
openInModrinthButton: {
id: 'button.open-in-modrinth',
defaultMessage: 'Open in Modrinth',
@@ -545,6 +549,10 @@ export const commonMessages = defineMessages({
id: 'button.copy-link',
defaultMessage: 'Copy link',
},
switchToVersionButton: {
id: 'button.switch-to-version',
defaultMessage: 'Switch to version',
},
switchVersionButton: {
id: 'button.switch-version',
defaultMessage: 'Switch version',
+8
View File
@@ -89,6 +89,14 @@ export function isEditableFile(ext: string): boolean {
return isCodeFile(ext) || isTextFile(ext)
}
/**
* Check if a file can be opened in the file editor
*/
export function canOpenInFileEditor(filename: string): boolean {
const ext = getFileExtension(filename)
return !filename.includes('.') || isEditableFile(ext) || isImageFile(ext)
}
/**
* Get Ace editor language mode for a file extension
*/
@@ -47,12 +47,16 @@ export function versionMatchesCompatibilityTarget(
return false
}
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
if (target.projectType === 'datapack') {
return normalizedVersionLoaders.includes('datapack')
}
if (target.projectType && NON_MOD_PROJECT_TYPES.has(target.projectType)) {
return true
}
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
if (
target.projectType === 'modpack' &&
(normalizedVersionLoaders.length === 0 ||