Compare commits

...
Author SHA1 Message Date
Prospector c63558b678 whoops bring those back 2026-07-05 14:01:41 -07:00
Prospector 3145759e88 commonize keys 2026-07-05 13:58:31 -07:00
Prospector b1f2017b07 feat: port version page to app 2026-07-05 12:32:05 -07:00
Prospector 4b3e9e785b prepr 2026-07-05 11:50:05 -07:00
Prospector f7ca96a1a4 fix: project page in app not receiving instance context 2026-07-05 11:49:22 -07:00
Prospector ad4912d64d changelog + one more fix 2026-07-04 19:09:58 -07:00
ProspectorandGitHub 0002ae2479 fix: env filter showing in hosting search (#6612) 2026-07-04 18:47:30 -07:00
Prospector e4568330a7 changelog 2026-07-04 16:16:02 -07:00
ProspectorandGitHub b0108e0624 fix: environment filters (#6611) 2026-07-04 16:15:28 -07:00
Prospector ac63abadb7 changelog 2026-07-04 16:00:11 -07:00
23 changed files with 509 additions and 516 deletions
+16 -1
View File
@@ -180,7 +180,7 @@
"message": "Discover servers"
},
"app.browse.hide-added-servers": {
"message": "Hide already added servers"
"message": "Hide servers already added"
},
"app.browse.project-type.modpacks": {
"message": "Modpacks"
@@ -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."
},
+1 -1
View File
@@ -403,7 +403,7 @@ const messages = defineMessages({
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide already added servers',
defaultMessage: 'Hide servers already added',
},
installingToServer: {
id: 'app.browse.server.installing',
@@ -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
+31 -3
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>
@@ -293,6 +309,7 @@ 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 {
@@ -343,6 +360,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 +535,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),
+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',
@@ -4,6 +4,7 @@ import type {
BrowseInstallPlan,
BrowseSearchState,
CreationFlowContextValue,
EnvironmentSearchOverride,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
@@ -160,6 +161,7 @@ export function useServerInstallContent({
})
const serverHideInstalled = ref(false)
const serverContentServerOnly = ref(false)
const hideSelectedServerInstalls = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
@@ -348,10 +350,6 @@ export function useServerInstallContent({
filters.push({ type: 'plugin_loader', option: platform })
}
if (projectType.value?.id === 'mod') {
filters.push({ type: 'environment', option: 'server' })
}
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
for (const x of hiddenInstalledProjectIds.value) {
filters.push({
@@ -383,6 +381,28 @@ export function useServerInstallContent({
return filters
})
const showServerOnlyToggle = computed(() => !!serverData.value && projectType.value?.id === 'mod')
const serverEnvironmentOverride = computed<EnvironmentSearchOverride | undefined>(() => {
if (!showServerOnlyToggle.value) return undefined
if (serverContentServerOnly.value) {
return {
mode: 'include',
values: [
'server_only',
'dedicated_server_only',
'server_only_client_optional',
'client_or_server_prefers_both',
'client_or_server',
],
}
}
return {
mode: 'exclude',
values: ['client_only', 'singleplayer_only'],
}
})
function getCurrentServerInstallType(): BrowseInstallContentType {
const type = projectType.value?.id
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
@@ -737,6 +757,10 @@ export function useServerInstallContent({
serverHideInstalled.value = route.query.shi === 'true'
}
if (route.query.so && projectType.value?.id === 'mod') {
serverContentServerOnly.value = route.query.so === 'true'
}
watch(serverHideInstalled, (hideInstalled) => {
if (hideInstalled) {
syncHiddenInstalledProjectIds()
@@ -763,6 +787,9 @@ export function useServerInstallContent({
serverContentData,
serverFilters,
serverHideInstalled,
serverContentServerOnly,
showServerOnlyToggle,
serverEnvironmentOverride,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
+1 -1
View File
@@ -4164,7 +4164,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"
+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
@@ -158,6 +158,9 @@ const {
serverContentData,
serverFilters,
serverHideInstalled,
serverContentServerOnly,
showServerOnlyToggle,
serverEnvironmentOverride,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
@@ -378,10 +381,12 @@ const searchState = useBrowseSearch({
projectType: projectTypeId,
tags,
providedFilters: serverFilters,
environmentOverride: serverEnvironmentOverride,
search,
persistentQueryParams: ['sid', 'wid', 'shi', 'from'],
persistentQueryParams: ['sid', 'wid', 'shi', 'so', 'from'],
getExtraQueryParams: () => ({
shi: serverHideInstalled.value ? 'true' : undefined,
so: showServerOnlyToggle.value && serverContentServerOnly.value ? 'true' : undefined,
}),
maxResultsOptions: currentMaxResultsOptions,
displayMode: resultsDisplayMode,
@@ -469,6 +474,10 @@ provideBrowseManager({
queuedServerInstallCount.value > 0,
),
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
serverOnly: serverContentServerOnly,
showServerOnly: showServerOnlyToggle,
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
+56
View File
@@ -10,6 +10,62 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-05T01:48:48+00:00`,
product: 'app',
version: '0.15.7',
body: `## Fixed
- Fixed environment filters showing in server panel's browse page.`,
},
{
date: `2026-07-05T01:48:48+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed environment filters showing in server panel's browse page.
- Fixed server-specific filter options not being removed from the URL when toggled off.`,
},
{
date: `2026-07-04T23:15:47+00:00`,
product: 'app',
version: '0.15.6',
body: `## Fixed
- Fixed the functionality of the server environment filter in Discover.`,
},
{
date: `2026-07-04T23:15:47+00:00`,
product: 'web',
body: `## Fixed
- Fixed the functionality of the server environment filter in Discover.`,
},
{
date: `2026-07-04T23:15:47+00:00`,
product: 'hosting',
body: `## Changed
- Replaced the environment filters in the panel with a toggle for server-side only content that appears at the top of the filters.`,
},
{
date: `2026-07-04T22:59:54+00:00`,
product: 'app',
version: '0.15.5',
body: `## Changed
- Added cdn.serilum.com and workflow.serilum.com as domains that can bypass the image proxy.
- Allow modfolio.creeperkatze.dev to bypass image proxy.
## Fixed
- Fixed the update app button width being fixed width, causing issues with non-English translations.`,
},
{
date: `2026-07-04T22:59:54+00:00`,
product: 'web',
body: `## Changed
- Added cdn.serilum.com and workflow.serilum.com as domains that can bypass the image proxy.
- Refreshed project download modal UI.
- Allow modfolio.creeperkatze.dev to bypass image proxy.
- Added a guard to prevent \`.mrpacks\` to be uploaded as supplementary files.
## Fixed
- Fixed version page table's platform tag overflow issues.`,
},
{
date: `2026-07-03T15:16:21+00:00`,
product: 'web',
@@ -1,6 +1,6 @@
<template>
<div v-bind="$attrs">
<div v-if="divider && !!slots.title" class="flex items-center gap-4 mb-4">
<div v-if="divider && (!!slots.title || !!slots.button)" class="flex items-center gap-4 mb-4">
<button
:class="
buttonClass ??
@@ -24,7 +24,7 @@
<hr class="h-px w-full border-none bg-divider" aria-hidden="true" />
</div>
<button
v-else-if="!!slots.title"
v-else-if="!!slots.title || !!slots.button"
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
>
@@ -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>
@@ -7,10 +7,16 @@
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
:open-by-default="openByDefault !== undefined ? openByDefault : true"
>
<template #title>
<slot name="header" :filter="filterType">
<h2 class="font-semibold">{{ filterType.formatted_name }}</h2>
</slot>
<template #button="{ open }">
<div class="flex items-center gap-1 w-full text-contrast">
<slot name="header" :filter="filterType">
<h2 class="text-base font-semibold text-red">{{ filterType.formatted_name }}</h2>
</slot>
<DropdownIcon
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-primary group-hover:text-contrast"
:class="{ 'rotate-180': open }"
/>
</div>
</template>
<template
v-if="
@@ -39,7 +45,7 @@
</div>
</template>
<template v-if="locked" #default>
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider">
<p class="m-0 font-bold items-center">
<slot :name="`locked-${filterType.id}`">
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
@@ -4,7 +4,13 @@ import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebugLogger } from '#ui/composables/debug-logger'
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
import type {
EnvironmentSearchOverride,
FilterType,
FilterValue,
ProjectType,
SortType,
} from '#ui/utils/search'
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
import { useServerSearch } from '#ui/utils/server-search'
@@ -18,6 +24,7 @@ export interface UseBrowseSearchOptions {
categories: Labrinth.Tags.v2.Category[]
}>
providedFilters?: ComputedRef<FilterValue[]>
environmentOverride?: ComputedRef<EnvironmentSearchOverride | undefined>
search: (params: string) => Promise<BrowseSearchResponse>
persistentQueryParams: string[]
getExtraQueryParams?: () => Record<string, string | undefined>
@@ -82,7 +89,12 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
sortTypes,
requestParams,
createPageParams,
} = useSearch(projectTypes, options.tags, options.providedFilters ?? computed(() => []))
} = useSearch(
projectTypes,
options.tags,
options.providedFilters ?? computed(() => []),
options.environmentOverride ?? computed(() => undefined),
)
const {
serverCurrentSortType,
@@ -262,9 +274,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
const extraParams = options.getExtraQueryParams?.() ?? {}
for (const [key, value] of Object.entries(extraParams)) {
if (value !== undefined) {
persistentParams[key] = value
}
persistentParams[key] = value
}
const params = {
@@ -164,7 +164,11 @@ const messages = defineMessages({
<SearchFilterControl
v-else
v-model:selected-filters="ctx.currentFilters.value"
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
:filters="
ctx.filters.value.filter(
(f) => f.display !== 'none' && !(ctx.hiddenFilterTypes?.value ?? []).includes(f.id),
)
"
:provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy"
@@ -69,6 +69,10 @@ export interface BrowseManagerContext {
hideSelected?: Ref<boolean>
showHideSelected?: ComputedRef<boolean>
hideSelectedLabel?: ComputedRef<string>
serverOnly?: Ref<boolean>
showServerOnly?: ComputedRef<boolean>
serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -3,7 +3,7 @@ import { InfoIcon, XIcon } from '@modrinth/assets'
import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -15,6 +15,7 @@ const { formatMessage } = useVIntl()
const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
function closeFiltersMenu() {
if (ctx.filtersMenuOpen) {
@@ -37,7 +38,7 @@ const buttonClass = computed(() => {
if (isApp.value) {
return 'button-animation flex flex-col gap-1 px-3 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg'
}
return 'button-animation flex flex-col gap-1 px-6 py-3 w-full bg-transparent cursor-pointer border-none'
return 'button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none'
})
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
@@ -99,38 +100,60 @@ function getFilterOpenByDefault(filterId: string): boolean {
</div>
<div
v-if="ctx.showHideInstalled?.value || ctx.showHideSelected?.value"
v-if="
ctx.showHideInstalled?.value || ctx.showHideSelected?.value || ctx.showServerOnly?.value
"
:class="
isApp
? 'flex flex-col gap-3 border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised p-4'
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised border-solid border-surface-4 border p-4'
"
>
<Checkbox
<label
v-if="ctx.showServerOnly?.value"
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
>
{{ ctx.serverOnlyLabel?.value ?? formatMessage(commonMessages.serverOnlyLabel) }}
<Toggle
v-model="ctx.serverOnly!.value"
small
class="shrink-0"
@update:model-value="ctx.onFilterChange()"
/>
</label>
<label
v-if="ctx.showHideInstalled?.value"
v-model="ctx.hideInstalled!.value"
:label="
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
>
{{
ctx.hideInstalledLabel?.value ?? formatMessage(commonMessages.hideInstalledContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
<Checkbox
}}
<Toggle
v-model="ctx.hideInstalled!.value"
small
class="shrink-0"
@update:model-value="ctx.onFilterChange()"
/>
</label>
<label
v-if="ctx.showHideSelected?.value"
v-model="ctx.hideSelected!.value"
:label="
ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel)
"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
/>
class="flex cursor-pointer items-center justify-between gap-3 text-contrast font-medium"
>
{{ ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel) }}
<Toggle
v-model="ctx.hideSelected!.value"
small
class="shrink-0"
@update:model-value="ctx.onFilterChange()"
/>
</label>
</div>
<template v-if="ctx.isServerType.value">
<SearchSidebarFilter
v-for="filterType in ctx.serverFilterTypes.value.filter((f) => f.options.length > 0)"
v-for="filterType in ctx.serverFilterTypes.value.filter(
(f) => f.options.length > 0 && !hiddenFilterTypes.includes(f.id),
)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="ctx.serverCurrentFilters.value"
v-model:toggled-groups="ctx.serverToggledGroups.value"
@@ -143,7 +166,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
:open-by-default="getFilterOpenByDefault(filterType.id)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg font-semibold'">
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-base font-semibold'">
{{ filterType.formatted_name }}
</h3>
</template>
@@ -151,7 +174,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in ctx.filters.value.filter((f) => f.display !== 'none')"
v-for="filter in ctx.filters.value.filter(
(f) => f.display !== 'none' && !hiddenFilterTypes.includes(f.id),
)"
:key="`filter-${filter.id}`"
v-model:selected-filters="ctx.currentFilters.value"
v-model:toggled-groups="ctx.toggledGroups.value"
+11 -2
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}}"
@@ -2190,7 +2196,7 @@
"defaultMessage": "Game version"
},
"label.hide-installed-content": {
"defaultMessage": "Hide already installed content"
"defaultMessage": "Hide content already installed"
},
"label.hide-selected-content": {
"defaultMessage": "Hide selected content"
@@ -2294,6 +2300,9 @@
"label.server": {
"defaultMessage": "Server"
},
"label.server-only": {
"defaultMessage": "Server only"
},
"label.servers": {
"defaultMessage": "Servers"
},
+13 -1
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',
@@ -429,12 +433,16 @@ export const commonMessages = defineMessages({
},
hideInstalledContentLabel: {
id: 'label.hide-installed-content',
defaultMessage: 'Hide already installed content',
defaultMessage: 'Hide content already installed',
},
hideSelectedContentLabel: {
id: 'label.hide-selected-content',
defaultMessage: 'Hide selected content',
},
serverOnlyLabel: {
id: 'label.server-only',
defaultMessage: 'Server only',
},
installedModpackTitle: {
id: 'label.installed-modpack',
defaultMessage: 'Installed modpack',
@@ -541,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',
+53 -25
View File
@@ -60,6 +60,10 @@ export type FilterValue = {
negative?: boolean
}
export type EnvironmentSearchOverride =
| { mode: 'include'; values: string[] }
| { mode: 'exclude'; values: string[] }
export const LOADER_FILTER_TYPES = [
'mod_loader',
'plugin_loader',
@@ -112,6 +116,7 @@ export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
providedFilters: Ref<FilterValue[]>,
environmentOverride: Ref<EnvironmentSearchOverride | undefined> = ref(undefined),
) {
const query = ref('')
const maxResults = ref(20)
@@ -505,22 +510,34 @@ export function useSearch(
}
// Environment facets
const client = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'client',
)
const server = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'server',
)
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
if (envGroup.length === 1) {
const [field, val] = envGroup[0].split(':')
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
} else if (envGroup.length > 1) {
const conditions = envGroup.map((f) => {
const [field, val] = f.split(':')
return `${field} = ${formatSearchFilterValue(val)}`
})
parts.push(`(${conditions.join(' OR ')})`)
const override = environmentOverride.value
if (override) {
if (override.values.length === 1) {
const operator = override.mode === 'include' ? '=' : '!='
parts.push(`environment ${operator} ${formatSearchFilterValue(override.values[0])}`)
} else if (override.values.length > 1) {
const operator = override.mode === 'include' ? 'IN' : 'NOT IN'
const quoted = override.values.map(formatSearchFilterValue).join(', ')
parts.push(`environment ${operator} [${quoted}]`)
}
} else {
const client = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'client',
)
const server = filterValues.some(
(filter) => filter.type === 'environment' && filter.option === 'server',
)
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
if (envGroup.length === 1) {
const [field, val] = envGroup[0].split(':')
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
} else if (envGroup.length > 1) {
const conditions = envGroup.map((f) => {
const [field, val] = f.split(':')
return `${field} = ${formatSearchFilterValue(val)}`
})
parts.push(`(${conditions.join(' OR ')})`)
}
}
}
@@ -777,17 +794,28 @@ function mapProjectTypeToSearch(projectType: ProjectType): string {
function getEnvironmentFilterGroups(client: boolean, server: boolean): string[][] {
const groups: string[][] = []
if (client && server) {
groups.push(
['client_side:required', 'client_side:optional', 'client_side:unsupported'],
['server_side:required', 'server_side:optional'],
)
groups.push([
'environment:client_only_server_optional',
'environment:server_only_client_optional',
'environment:client_and_server',
'environment:client_or_server',
'environment:client_or_server_prefers_both',
])
} else if (client) {
groups.push(
['client_side:optional', 'client_side:required'],
['server_side:optional', 'server_side:unsupported'],
)
groups.push([
'environment:client_only',
'environment:client_only_server_optional',
'environment:client_or_server_prefers_both',
'environment:client_or_server',
])
} else if (server) {
groups.push(['server_side:optional', 'server_side:required'])
groups.push([
'environment:server_only',
'environment:dedicated_server_only',
'environment:server_only_client_optional',
'environment:client_or_server_prefers_both',
'environment:client_or_server',
])
}
return groups
}