Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 8b4f913605 feat: qa 2026-05-23 21:35:37 +01:00
Calum H. (IMB11) 8e61f042b7 feat: qa + app routing bugs 2026-05-23 21:09:18 +01:00
Calum H. (IMB11) 8301620b76 feat: migrate all headers to use PageHeader shared component. 2026-05-23 19:12:46 +01:00
Calum H. (IMB11) 3a69e8818a fix: ssr for instances subpages with middleware 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 8944437f1b fix: ssr 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 65e250f7f2 fix: header power state 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 5e34efea22 feat: header migration pt 2 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) e36f8a42f5 feat: PageHeader migration start 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) c5f1fa1154 fix: header issues 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 443e17f9d5 fix: lint 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 7622abe59b qa: pass 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 76036602a3 chore: rename worlds -> instances 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) e2ac1274ac fix: modpack linked text alignment 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) ceef2811f4 fix: worlds layout 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 739a1baef5 feat: fix btn sizing 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) 1e1a75ab90 fix: lint 2026-05-23 19:09:14 +01:00
Calum H. (IMB11) b6f25e9ed4 chore: clean up wrapped layout folder structure 2026-05-23 19:07:40 +01:00
Calum H. (IMB11) ef4c2e3161 feat: reshuffle layout for worlds 2026-05-23 19:05:31 +01:00
Calum H. (IMB11) 7911ce8e4a feat: world card alignment 2026-05-23 19:02:50 +01:00
Calum H. (IMB11) fcaaf534a8 fix: lint + i18n 2026-05-23 19:02:50 +01:00
Calum H. (IMB11) 3cd18b7205 feat: world switching page implementation 2026-05-23 19:02:50 +01:00
f9d47e8edc feat: Add new "Enabled" sorting button next to "Disabled" (#6000)
* feat: Added new "Enabled" sorting button next to "Disabled"

* Updated when filter buttons show

- When all mods are disabled, only "Disabled" shows
- When all mods are enabled, only "Enabled" shows
- When there disabled and enabled mods, then only both buttons show.

* Updated when filter buttons show

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-05-23 16:12:14 +00:00
a58bc3dc21 feat: java installation ui improvements (#5731)
* Clean impl of java installation ui improvements

* Migrate composable to ts

* Migrate to ButtonStyled, fix coloring

* Fix lint

* Fix clearing java path not refreshing state

* fix: use Table component + install btn disabled state tooltip

---------

Signed-off-by: Arthur <creeperkatze.dev@gmail.com>
Signed-off-by: Arthur <contact@creeperkatze.dev>
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-23 14:46:12 +00:00
ProspectorandGitHub 1e46444fb0 fix: checklist forcing redirects (#6173) 2026-05-22 15:53:22 -07:00
ProspectorandGitHub 1511e55597 fix: skip reviewed projects in queue (#6171) 2026-05-22 14:54:50 -07:00
aecsocketandGitHub 5727e156ed Fetch project analytics events on analytics get (#6143)
* Fetch project analytics events

* fix

* post-query ua bucketing

* fmt
2026-05-22 18:32:33 +00:00
Calum H.andGitHub 657186398d fix: remove broken roadmap reference (#6165)
Signed-off-by: Calum H. <calum@modrinth.com>
2026-05-22 18:10:01 +00:00
Michael H. 3ab2273782 chore: prepr 2026-05-22 19:58:11 +02:00
ProspectorandGitHub 893ec00fc6 feat: add external dep sorting to moderation queue (#6161)
* feat: add external dep sorting to moderation queue

* prepr
2026-05-21 16:40:13 -07:00
Prospector aa7dd1d210 web 2026-05-21 16:23:30 -07:00
Calum H.andGitHub f8733b0488 feat: changelog 21st may 2026 (#6158)
* feat: changelog 21st may 2026

* fix: changelog
2026-05-21 22:27:14 +00:00
Calum H.andGitHub d077d44540 fix: content tab uniqueness regression (#6156)
* fix: content tab uniqueness regression

Closes: #6154

* fix: further regressions

* fix: lint

* fix: lint
2026-05-21 22:03:35 +00:00
aecsocketandGitHub 4e1a61d8b6 Adjust window occlusion threshold (#6157) 2026-05-21 21:48:39 +00:00
aecsocketandGitHub 71dee4de40 Add modpacks with external files filter to moderation queue (#6155)
* simplify query

* make it a tristate

* external deps count
2026-05-21 20:21:54 +00:00
SychicandGitHub f74fad0cae fix: typo in version changelog (#6153) 2026-05-21 19:38:11 +00:00
Truman GaoandGitHub 07e81ac036 fix: project embeds (#6152)
* fix: project embeds

* remove: params.id
2026-05-21 17:02:53 +00:00
Calum H.andGitHub 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00
Calum H.andGitHub 2f95c4c441 feat: changelog 20th may 2026 (#6148) 2026-05-20 12:51:36 -07:00
aecsocketandGitHub 451b2d0e44 Window occlusion checks on MacOS (#6135)
* wip: window occlusion checks on MacOS

* wip: occlusion works

* occlusion notification

* fix ci

* fix

* wire in hiding into macos occlusion

* remove debug logs

* fix

* clean up
2026-05-20 19:31:12 +00:00
Truman GaoandGitHub 215643c846 fix: server project type page title (#6147)
* fix: server project type page title

* pnpm prepr
2026-05-20 19:01:19 +00:00
Truman GaoandGitHub d8b1415f9c feat: swap date input to use date picker (#6146)
* feat: swap date input to use date picker

* feat: update date picker to analytics branch changes

* feat: polish date picker usage
2026-05-20 17:50:46 +00:00
Calum H.andGitHub 3eeb549d20 fix: intercom bubble positioning properly (#6111)
* feat: fix intercom properly

* fix: positioning size + css transition

* fix: lint

* fix: ts

* fix: nitpick
2026-05-20 17:15:46 +00:00
Calum H.andGitHub c3fe7b4232 feat: content management changes (#6104)
* feat: change modpack updating flow

* fix: pending install state loss

* fix: mods.vue perf problems

* chore: todo doc

* draft: try preload/fix suspense

* fix: lint
2026-05-20 17:07:35 +00:00
Prospector 079a10bba9 changelog 2026-05-20 09:01:26 -07:00
Prospector 3c3d5702ba fix: permissions page being displayed 2026-05-20 08:59:50 -07:00
Prospector a34576a2c3 changelog 2026-05-19 12:51:59 -07:00
ProspectorandGitHub d8e4915a31 fix: 404 error when changing project slug (#6139) 2026-05-19 19:21:23 +00:00
155 changed files with 10130 additions and 3260 deletions
@@ -9,8 +9,6 @@ body:
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true
- type: dropdown
id: projects
attributes:
Generated
+3
View File
@@ -10565,6 +10565,8 @@ version = "1.0.0-local"
dependencies = [
"async_zip",
"chrono",
"core-foundation 0.10.1",
"core-graphics",
"daedalus",
"dashmap",
"either",
@@ -10572,6 +10574,7 @@ dependencies = [
"hyper 1.7.0",
"hyper-util",
"native-dialog",
"objc2-app-kit",
"paste",
"path-util",
"serde",
+3
View File
@@ -64,6 +64,8 @@ clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
@@ -123,6 +125,7 @@ murmur2 = "0.1.0"
native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false }
p256 = "0.13.2"
parking_lot = "0.12.5"
paste = "1.0.15"
-1
View File
@@ -13,7 +13,6 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@intercom/messenger-js-sdk": "^0.0.14",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
+117 -97
View File
@@ -1,5 +1,4 @@
<script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import {
AuthFeature,
ModrinthApiError,
@@ -53,6 +52,7 @@ import {
providePopupNotificationManager,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -88,7 +88,6 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useIntercomPositioning } from '@/composables/intercom-positioning'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -132,15 +131,53 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const intercomBubblePositioning = useIntercomPositioning({ route, themeStore })
const {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
} = intercomBubblePositioning
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000
const credentials = ref()
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const keepAliveRouteComponents = computed(() => [
...new Set(
router
.getRoutes()
.map((route) => route.meta.keepAliveComponent)
.filter((name) => typeof name === 'string'),
),
])
function getRouteViewKey(viewRoute) {
const keepAliveKey = viewRoute.meta.keepAliveKey
if (typeof keepAliveKey === 'function') return keepAliveKey(viewRoute)
if (typeof keepAliveKey === 'string') return keepAliveKey
return undefined
}
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
const userId = credentials.value?.user_id ?? credentials.value?.user?.id ?? 'anonymous'
return `${userId}:${serverId ?? 'hosting'}`
})
const hostingIntercom = useHostingIntercom({
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
appId: 'ykeritl9',
fetchToken: fetchIntercomToken,
identityKey: hostingIntercomIdentityKey,
horizontalPadding: computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
),
})
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
@@ -175,7 +212,11 @@ provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
...intercomBubblePositioning.pageContext,
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
@@ -259,8 +300,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
clearIntercomBubbleStyles()
unsubscribeSidebarToggle()
await unlistenUpdateDownload?.()
})
@@ -459,6 +499,11 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({
},
})
router.beforeEach(async (to) => {
const redirect = await resolveLegacyServerInstanceTabRedirect(to)
if (redirect) return redirect
})
router.beforeEach(() => {
suspensePending = false
if (routerToken) loading.end(routerToken)
@@ -490,6 +535,50 @@ function onSuspensePending() {
suspenseToken = loading.begin()
}
async function resolveLegacyServerInstanceTabRedirect(to) {
if (!['ServerManageContent', 'ServerManageFiles', 'ServerManageBackups'].includes(to.name)) {
return null
}
const serverId = getRouteParam(to.params.id)
if (!serverId) return null
const tabPath =
to.name === 'ServerManageFiles' ? '/files' : to.name === 'ServerManageBackups' ? '/backups' : ''
const instancesPath = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
try {
const serverFull = await tauriApiClient.archon.servers_v1.get(serverId)
const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0]
if (world) {
return {
path: `${instancesPath}/${encodeURIComponent(world.id)}${tabPath}`,
query: to.query,
hash: to.hash,
replace: true,
}
}
} catch {
return {
path: instancesPath,
query: to.query,
hash: to.hash,
replace: true,
}
}
return {
path: instancesPath,
query: to.query,
hash: to.hash,
replace: true,
}
}
function getRouteParam(param) {
return Array.isArray(param) ? param[0] : param
}
function onSuspenseResolve() {
if (suspenseToken) {
loading.end(suspenseToken)
@@ -593,8 +682,6 @@ const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
setupAuthProvider(credentials, async (_redirectPath) => {
@@ -664,10 +751,6 @@ const hasPlus = computed(
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
let intercomBooting = false
let intercomBooted = false
async function fetchIntercomToken() {
const creds = await getCreds()
@@ -676,8 +759,10 @@ async function fetchIntercomToken() {
}
const params = new URLSearchParams()
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
params.set('server_id', route.params.id)
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
if (route.path.startsWith('/hosting/manage/') && typeof serverId === 'string') {
params.set('server_id', serverId)
}
const query = params.size > 0 ? `?${params.toString()}` : ''
@@ -693,69 +778,6 @@ async function fetchIntercomToken() {
return await response.json()
}
async function bootIntercom() {
if (
intercomBooting ||
intercomBooted ||
!hostingRouteActive.value ||
!credentials.value?.session
) {
return
}
intercomBooting = true
console.debug('[APP][INTERCOM] initializing secure support chat')
try {
const { token } = await fetchIntercomToken()
Intercom({
app_id: 'ykeritl9',
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
vertical_padding: intercomBubblePosition.value.verticalPadding,
})
intercomBooted = true
} catch (error) {
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
} finally {
intercomBooting = false
}
}
function shutdownHostingIntercom() {
if (!intercomBooted && !intercomBooting) return
shutdownIntercom()
intercomBooting = false
intercomBooted = false
}
watch(
intercomBubblePosition,
(position) => {
updateIntercomBubbleStyles(position)
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: position.horizontalPadding,
vertical_padding: position.verticalPadding,
})
}
},
{ immediate: true },
)
watch(
[hostingRouteActive, credentials],
([active]) => {
if (active) {
void bootIntercom()
} else {
shutdownHostingIntercom()
}
},
{ immediate: true },
)
watch(showAd, () => {
if (!showAd.value) {
hide_ads_window(true)
@@ -1490,11 +1512,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
{{ formatMessage(messages.authUnreachableBody) }}
</Admonition>
<RouterView v-slot="{ Component }">
<RouterView v-slot="{ Component, route: viewRoute }">
<template v-if="Component">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component>
</Suspense>
<KeepAlive :include="keepAliveRouteComponents" :max="3">
<Suspense
:timeout="ROUTE_SUSPENSE_TIMEOUT_MS"
@pending="onSuspensePending"
@resolve="onSuspenseResolve"
>
<component :is="Component" :key="getRouteViewKey(viewRoute)"></component>
</Suspense>
</KeepAlive>
</template>
</RouterView>
</div>
@@ -1822,14 +1850,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.intercom-lightweight-app-launcher,
.intercom-launcher-frame,
iframe[name='intercom-launcher-frame'] {
right: var(--app-support-launcher-right, 20px) !important;
bottom: var(--app-support-launcher-bottom, 20px) !important;
z-index: 9 !important;
}
.mac {
.app-grid-statusbar {
padding-left: 5rem;
@@ -1,35 +1,33 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<div class="auto-detect-modal">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div>
</div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text">
<span>{{ javaInstall.version }}</span>
</div>
<div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span>
</div>
<div class="table-cell table-text manage">
<ButtonStyled v-if="currentSelected.path === javaInstall.path">
<button disabled><CheckIcon /> Selected</button>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
<span class="font-semibold text-primary">{{ value }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</button>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
</ButtonStyled>
</div>
</div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div>
</div>
</div>
<div class="input-group push-right">
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button @click="$refs.detectJavaModal.hide()">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
</button>
@@ -40,7 +38,7 @@
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -52,6 +50,11 @@ const { handleError } = injectNotificationManager()
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -77,25 +80,3 @@ function setJavaInstall(javaInstall) {
})
}
</script>
<style lang="scss" scoped>
.auto-detect-modal {
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;
}
span {
display: inherit;
align-items: center;
justify-content: center;
}
padding: 0.5rem;
}
}
.manage {
display: flex;
gap: 0.5rem;
}
</style>
@@ -1,85 +1,104 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<div :id="props.id" class="toggle-setting" :class="{ compact }">
<div class="input-with-status">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button :disabled="props.disabled || installingJava" @click="reinstallJava">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="props.disabled" @click="autoDetect">
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="props.disabled" @click="handleJavaFileInput()">
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<ButtonStyled v-if="testingJava">
<button disabled>Testing...</button>
</ButtonStyled>
<ButtonStyled v-else-if="testingJavaSuccess === true">
<button disabled>
<CheckIcon />
Success
</button>
</ButtonStyled>
<ButtonStyled v-else-if="testingJavaSuccess === false">
<button disabled>
<XIcon />
Failed
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button :disabled="props.disabled" @click="testJava">
<PlayIcon />
Test
</button>
</ButtonStyled>
</span>
</div>
</template>
<script setup>
import {
CheckIcon,
CheckCircleIcon,
DownloadIcon,
FolderSearchIcon,
PlayIcon,
RefreshCwIcon,
SearchIcon,
XIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
version: {
type: Number,
required: false,
@@ -110,29 +129,36 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const {
testingJava,
javaTestResult: testingJavaSuccess,
testJavaInstallationDebounced,
testJavaInstallation,
} = useJavaTest()
const installingJava = ref(false)
const hoveringTest = ref(false)
let hasInitialized = false
async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
props.version,
)
testingJava.value = false
trackEvent('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value,
})
setTimeout(() => {
testingJavaSuccess.value = null
}, 2000)
async function runTest(path) {
await testJavaInstallation(path, props.version, true)
}
watch(
() => props.modelValue?.path,
(newPath) => {
if (newPath) {
if (!hasInitialized) {
testJavaInstallation(newPath, props.version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, props.version)
}
}
},
{ immediate: true },
)
async function handleJavaFileInput() {
const filePath = await open()
@@ -142,6 +168,7 @@ async function handleJavaFileInput() {
result = {
path: filePath.path ?? filePath,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -175,6 +202,7 @@ async function reinstallJava() {
result = {
path: path,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -186,13 +214,23 @@ async function reinstallJava() {
emit('update:modelValue', result)
installingJava.value = false
runTest(result.path)
}
</script>
<style lang="scss" scoped>
.input-with-status {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
}
.installation-input {
width: 100% !important;
flex-grow: 1;
flex: 1 1 0;
min-width: 0;
}
.toggle-setting {
@@ -215,12 +253,4 @@ async function reinstallJava() {
gap: 0.5rem;
margin: 0;
}
.test-success {
color: var(--color-green);
}
.test-fail {
color: var(--color-red);
}
</style>
@@ -35,7 +35,7 @@ const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const [
fabric_versions,
@@ -113,6 +113,7 @@ provideAppBackup({
})
provideInstallationSettings({
closeSettings: closeModal,
loading: ref(false),
installationInfo: computed(() => {
const rows = [
@@ -1,6 +1,15 @@
<script setup lang="ts">
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import {
CheckCircleIcon,
CoffeeIcon,
FolderSearchIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
@@ -8,9 +17,11 @@ import {
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, readonly, ref, watch } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
@@ -25,9 +36,54 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!instance.value.java_path)
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
const overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() =>
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
)
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
}
})
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
useJavaTest()
const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
}
}
},
{ immediate: true },
)
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
async function handleBrowseJava() {
const result = await open({ multiple: false })
if (result) {
javaPath.value = result
}
}
function handleDetectJava() {
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
}
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
@@ -51,8 +107,8 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
const editProfileObject = computed(() => {
return {
java_path:
overrideJavaInstall.value && javaInstall.value.path !== ''
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
overrideJavaInstall.value && javaPath.value
? javaPath.value.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
@@ -71,7 +127,7 @@ const editProfileObject = computed(() => {
watch(
[
overrideJavaInstall,
javaInstall,
javaPath,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
@@ -90,17 +146,45 @@ const messages = defineMessages({
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Custom Java installation',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
defaultMessage: '/path/to/java',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
defaultMessage: 'Enter Java arguments...',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
defaultMessage: 'Enter environmental variables...',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
@@ -111,43 +195,86 @@ const messages = defineMessages({
<template>
<div>
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
<template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall">
<CheckCircleIcon class="text-brand-green h-4 w-4" />
<span>Using default Java {{ optimalJava.major_version }} installation:</span>
</template>
<template v-else-if="optimalJava">
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set
one below:</span
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
</template>
<template v-else>
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not automatically determine a Java installation to use. Please set one
below:</span
>
</template>
<div class="flex gap-2 items-center">
<StyledInput
:model-value="activePath"
:disabled="!overrideJavaInstall"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
:disabled="!overrideJavaInstall || testingJava"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<ButtonStyled>
<button @click="handleDetectJava">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="javaInstall && !overrideJavaInstall"
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
>
{{ javaInstall.path }}
</div>
</template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -159,28 +286,36 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<StyledInput
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
placeholder="Enter java arguments..."
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<StyledInput
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
placeholder="Enter environmental variables..."
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
@@ -45,12 +45,19 @@ const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
const instanceRef = computed(() => props.instance)
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
watch(
@@ -113,9 +120,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
},
])
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function show(tabIndex?: number) {
if (props.instance.linked_data?.project_id) {
queryClient.prefetchQuery({
@@ -129,7 +133,7 @@ function show(tabIndex?: number) {
}
}
defineExpose({ show })
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
@@ -2,16 +2,21 @@ import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
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 { get_by_profile_path } from '@/helpers/process'
import { kill, list as listInstances } from '@/helpers/profile.js'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, getServerLatency } from '@/helpers/worlds'
import { add_server_to_profile } from '@/helpers/worlds'
import { getServerAddress } from '@/store/install.js'
interface BrowseServerInstance {
@@ -65,14 +70,13 @@ const messages = defineMessages({
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const { formatMessage } = useVIntl()
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[]) {
@@ -145,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 }
}),
)
}
@@ -307,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,
})
}
@@ -1,121 +0,0 @@
import { computed, onUnmounted, ref } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
interface ThemeStore {
toggleSidebar: boolean
$subscribe: (callback: () => void) => () => void
}
interface IntercomBubblePosition {
horizontalPadding: number
verticalPadding: number
}
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const INTERCOM_BUBBLE_WIDTH = 72
const INTERCOM_BUBBLE_RIGHT_VAR = '--app-support-launcher-right'
const INTERCOM_BUBBLE_BOTTOM_VAR = '--app-support-launcher-bottom'
export function useIntercomPositioning({
route,
themeStore,
}: {
route: RouteLocationNormalizedLoaded
themeStore: ThemeStore
}) {
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
onUnmounted(unsubscribeSidebarToggle)
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const defaultIntercomBubbleHorizontalPadding = computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
)
const intercomBubbleRequestedHorizontalPadding = ref<number | null>(null)
const intercomBubbleHorizontalPadding = computed(
() =>
intercomBubbleRequestedHorizontalPadding.value ??
defaultIntercomBubbleHorizontalPadding.value,
)
const intercomBubbleVerticalClearance = ref<number | null>(null)
const intercomBubblePosition = computed(() => ({
horizontalPadding: intercomBubbleHorizontalPadding.value,
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
}))
const intercomBubbleHorizontalPaddingRequests = new Map<symbol, number>()
const intercomBubbleClearanceRequests = new Map<symbol, number>()
function requestIntercomBubbleHorizontalPadding(id: symbol, padding: number | null) {
if (padding === null) {
intercomBubbleHorizontalPaddingRequests.delete(id)
} else {
intercomBubbleHorizontalPaddingRequests.set(id, padding)
}
intercomBubbleRequestedHorizontalPadding.value =
intercomBubbleHorizontalPaddingRequests.size > 0
? Math.max(...intercomBubbleHorizontalPaddingRequests.values())
: null
}
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
if (clearance === null) {
intercomBubbleClearanceRequests.delete(id)
} else {
intercomBubbleClearanceRequests.set(id, clearance)
}
intercomBubbleVerticalClearance.value =
intercomBubbleClearanceRequests.size > 0
? Math.max(...intercomBubbleClearanceRequests.values())
: null
}
function updateIntercomBubbleStyles({
horizontalPadding,
verticalPadding,
}: IntercomBubblePosition) {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty(INTERCOM_BUBBLE_RIGHT_VAR, `${horizontalPadding}px`)
document.documentElement.style.setProperty(INTERCOM_BUBBLE_BOTTOM_VAR, `${verticalPadding}px`)
}
function clearIntercomBubbleStyles() {
if (typeof document === 'undefined') return
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_RIGHT_VAR)
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_BOTTOM_VAR)
}
return {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
pageContext: {
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: {
width: ref(INTERCOM_BUBBLE_WIDTH),
horizontalPadding: intercomBubbleHorizontalPadding,
requestHorizontalPadding: requestIntercomBubbleHorizontalPadding,
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
},
},
}
}
@@ -0,0 +1,52 @@
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { test_jre } from '@/helpers/jre.js'
export default function useJavaTest() {
const testingJava = ref(false)
const javaTestResult = ref<boolean | null>(null)
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function runJavaTest(path: string, version: number, track = true) {
if (testDebounceTimer) {
clearTimeout(testDebounceTimer)
testDebounceTimer = null
}
if (!path) {
javaTestResult.value = null
return
}
testingJava.value = true
try {
javaTestResult.value = await test_jre(path, version)
} catch {
javaTestResult.value = false
}
testingJava.value = false
if (track) {
trackEvent('JavaTest', { path, success: javaTestResult.value })
}
}
function testJavaInstallationDebounced(path: string, version: number, delay = 600) {
if (testDebounceTimer) clearTimeout(testDebounceTimer)
if (!path) {
javaTestResult.value = null
return
}
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
}
async function testJavaInstallation(path: string, version: number, track = false) {
await runJavaTest(path, version, track)
}
return {
testingJava,
javaTestResult,
testJavaInstallationDebounced,
testJavaInstallation,
}
}
@@ -0,0 +1,100 @@
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '@modrinth/ui'
import {
get_content_items,
get_linked_modpack_info,
type LinkedModpackInfo,
} from '@/helpers/profile'
import { get_categories } from '@/helpers/tags.js'
import type { CacheBehaviour } from '@/helpers/types'
export type InstanceContentData = {
path: string
contentItems: ContentItem[] | null
modpack: InstanceContentModpackData | null
}
export type InstanceContentModpackData = {
project: ContentModpackCardProject
version: ContentModpackCardVersion
owner: ContentOwner | null
categories: ContentModpackCardCategory[]
hasUpdate: boolean
updateVersionId: string | null
}
export async function loadInstanceContentData(
path: string,
cacheBehaviour?: CacheBehaviour,
onError?: (error: Error) => unknown,
): Promise<InstanceContentData> {
const [contentItems, modpackInfo, allCategories] = await Promise.all([
get_content_items(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
get_linked_modpack_info(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
get_categories().catch((error) => handleLoadError(error, onError)),
])
return {
path,
contentItems: (contentItems as ContentItem[] | null | undefined) ?? null,
modpack: normalizeLinkedModpackInfo(
modpackInfo as LinkedModpackInfo | null | undefined,
allCategories as ContentModpackCardCategory[] | null | undefined,
),
}
}
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
onError?.(error as Error)
return null
}
function normalizeLinkedModpackInfo(
modpackInfo: LinkedModpackInfo | null | undefined,
allCategories: ContentModpackCardCategory[] | null | undefined,
): InstanceContentModpackData | null {
if (!modpackInfo) return null
return {
project: {
...modpackInfo.project,
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
},
version: {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
},
owner: modpackInfo.owner
? {
...modpackInfo.owner,
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
}
: null,
categories: resolveLinkedModpackCategories(modpackInfo, allCategories),
hasUpdate: modpackInfo.has_update,
updateVersionId: modpackInfo.update_version_id,
}
}
function resolveLinkedModpackCategories(
modpackInfo: LinkedModpackInfo,
allCategories: ContentModpackCardCategory[] | null | undefined,
) {
if (!allCategories || !modpackInfo.project.categories) return []
const seen = new Set<string>()
return allCategories.filter((category) => {
if (modpackInfo.project.categories.includes(category.name) && !seen.has(category.name)) {
seen.add(category.name)
return true
}
return false
})
}
+8 -1
View File
@@ -147,9 +147,16 @@ export async function get_mod_full_path(path: string, projectPath: string): Prom
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
}
export interface JavaVersion {
parsed_version: number
version: string
architecture: string
path: string
}
// Get optimal java version from profile
// Returns a java version
export async function get_optimal_jre_key(path: string): Promise<string | null> {
export async function get_optimal_jre_key(path: string): Promise<JavaVersion | null> {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
}
@@ -143,6 +143,9 @@
"app.browse.server.installing": {
"message": "Installing"
},
"app.browse.server.world-fallback-name": {
"message": "Instance"
},
"app.creation-modal.installing-modpack.description": {
"message": "{fileName}"
},
@@ -611,6 +614,24 @@
"instance.settings.tabs.java": {
"message": "Java and memory"
},
"instance.settings.tabs.java.custom-environment-variables": {
"message": "Custom environment variables"
},
"instance.settings.tabs.java.custom-java-arguments": {
"message": "Custom Java arguments"
},
"instance.settings.tabs.java.custom-java-installation": {
"message": "Custom Java installation"
},
"instance.settings.tabs.java.custom-memory-allocation": {
"message": "Custom memory allocation"
},
"instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..."
},
"instance.settings.tabs.java.enter-java-arguments": {
"message": "Enter Java arguments..."
},
"instance.settings.tabs.java.environment-variables": {
"message": "Environment variables"
},
@@ -626,6 +647,9 @@
"instance.settings.tabs.java.java-memory": {
"message": "Memory allocated"
},
"instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java"
},
"instance.settings.tabs.window": {
"message": "Window"
},
+53 -36
View File
@@ -10,6 +10,7 @@ import {
} from '@modrinth/assets'
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
import {
BrowseInstallHeader,
BrowsePageLayout,
BrowseSidebar,
commonMessages,
@@ -22,8 +23,9 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
SelectedProjectsFloatingBar,
useBrowseSearch,
useDebugLogger,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -56,18 +58,25 @@ import {
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
defineOptions({
name: 'Browse',
})
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
injectServerInstall()
const { install: installVersion } = injectContentInstall()
const queryClient = useQueryClient()
const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
const serverInstallContent = createServerInstallContent({
serverSetupModalRef,
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'),
})
provideServerInstallContent(serverInstallContent)
const {
serverIdQuery,
@@ -77,6 +86,7 @@ const {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContextWorldName,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
@@ -103,8 +113,6 @@ const {
handleServerModpackFlowCreate,
markServerProjectInstalled,
} = serverInstallContent
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
.catch(handleError)
@@ -182,22 +190,10 @@ watchServerContextChanges()
await initInstanceContext()
async function initInstanceContext() {
debugLog('initInstanceContext', {
queryI: route.query.i,
queryAi: route.query.ai,
querySid: route.query.sid,
queryWid: route.query.wid,
queryFrom: route.query.from,
})
await initServerContext()
if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
debugLog('instance loaded', {
name: instance.value?.name,
loader: instance.value?.loader,
gameVersion: instance.value?.game_version,
})
if (route.query.from === 'worlds') {
get_profile_worlds(route.query.i as string)
@@ -205,34 +201,29 @@ async function initInstanceContext() {
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
})
.catch(handleError)
} else {
getInstalledProjectIds(route.query.i as string)
.then((ids) => {
debugLog('installedProjectIds loaded', { count: ids?.length })
installedProjectIds.value = ids
})
.catch(handleError)
}
if (instance.value?.linked_data?.project_id) {
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
const projectV3 = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
).catch(handleError)
if (projectV3?.minecraft_server != null) {
debugLog('instance is a server instance')
isServerInstance.value = true
}
}
}
if (route.query.ai && !(route.params.projectType === 'modpack')) {
debugLog('setting instanceHideInstalled from query', route.query.ai)
instanceHideInstalled.value = route.query.ai === 'true'
}
}
@@ -283,7 +274,7 @@ function syncHiddenServerContentProjectIds() {
watch(
serverContentProjectIds,
() => {
if (!hiddenServerContentProjectIdsInitialized.value) {
if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) {
syncHiddenServerContentProjectIds()
}
},
@@ -356,11 +347,9 @@ const {
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
debugLog('went offline')
offline.value = true
})
window.addEventListener('online', () => {
debugLog('went online')
offline.value = false
})
@@ -405,6 +394,10 @@ const messages = defineMessages({
id: 'app.browse.back-to-instance',
defaultMessage: 'Back to instance',
},
worldFallbackName: {
id: 'app.browse.server.world-fallback-name',
defaultMessage: 'Instance',
},
serverInstanceContentWarning: {
id: 'app.browse.server-instance-content-warning',
defaultMessage:
@@ -464,6 +457,9 @@ const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
watch(
() => route.params.projectType as ProjectType,
async (newType) => {
if (!browseRouteActive.value) {
return
}
if (isSetupServerContext.value) {
enforceSetupModpackRoute(newType)
if (newType !== 'modpack') return
@@ -471,11 +467,9 @@ watch(
if (!newType || newType === projectType.value) return
debugLog('projectType route param changed', { from: projectType.value, to: newType })
projectType.value = newType
if (!route.query.i && instance.value) {
debugLog('instance context removed, resetting')
instance.value = null
installedProjectIds.value = null
instanceHideInstalled.value = false
@@ -545,8 +539,9 @@ const selectableProjectTypes = computed(() => {
const installContext = computed(() => {
if (isServerContext.value && serverContextServerData.value) {
return {
name: serverContextServerData.value.name,
name: serverContextWorldName.value ?? formatMessage(messages.worldFallbackName),
loader: serverContextServerData.value.loader ?? '',
loaderVersion: serverContextServerData.value.loader_version ?? '',
gameVersion: serverContextServerData.value.mc_version ?? '',
serverId: serverIdQuery.value,
upstream: serverContextServerData.value.upstream,
@@ -586,6 +581,12 @@ const installContext = computed(() => {
return null
})
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'BrowseInstallHeader',
)
const installingProjectIds = ref<Set<string>>(new Set())
function setProjectInstalling(projectId: string, installing: boolean) {
@@ -682,11 +683,10 @@ function getCardActions(
installed?: boolean
installing?: boolean
}
const isInstalled =
projectResult.installed ||
allInstalledIds.value.has(projectResult.project_id || '') ||
serverContentProjectIds.value.has(projectResult.project_id || '') ||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
const isInstalled = isServerContext.value
? serverContentProjectIds.value.has(projectResult.project_id || '') ||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
: projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '')
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
if (
@@ -838,7 +838,6 @@ function onSearchResultInstalled(id: string) {
}
async function search(requestParams: string) {
debugLog('searching v3', requestParams)
const isServer = projectType.value === 'server'
const rawResults = await queryClient.fetchQuery({
@@ -920,6 +919,7 @@ const lockedFilterMessages = computed(() => ({
const searchState = useBrowseSearch({
projectType,
tags,
active: browseRouteActive,
providedFilters: combinedProvidedFilters,
search,
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
@@ -967,7 +967,12 @@ if (instance.value?.game_version) {
await searchState.refreshSearch()
function getProjectBrowseQuery() {
if (!installContext.value) return undefined
if (!browseRouteActive.value) {
return undefined
}
if (!installContext.value) {
return undefined
}
return {
...route.query,
b: route.fullPath,
@@ -1031,6 +1036,17 @@ provideBrowseManager({
<template>
<div class="flex flex-col gap-3 p-6">
<div
v-if="installContext"
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid border-divider bg-surface-1 px-6 pt-6"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader bottom-padding />
</div>
<SelectedProjectsFloatingBar v-if="installContext" />
<BrowsePageLayout>
<template #after>
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
@@ -1057,7 +1073,8 @@ provideBrowseManager({
@browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate"
/>
<Teleport to="#sidebar-teleport-target">
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
<BrowseSidebar />
</Teleport>
</div>
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId],
queryKey: ['backups', 'list', serverId, worldId.value],
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
staleTime: 30_000,
})
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId],
queryKey: ['content', 'list', 'v1', serverId, worldId.value],
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
staleTime: 30_000,
@@ -4,7 +4,6 @@
:server-id="serverId"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:auth-user="authUser"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
@@ -26,7 +25,7 @@
"
>
<template #default="{ onReinstall, onReinstallFailed }">
<RouterView v-slot="{ Component }">
<RouterView v-slot="{ Component }" :route="managedRoute">
<template v-if="Component">
<Suspense>
<component
@@ -47,26 +46,37 @@ import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
})
const managedRoute = shallowRef(router.currentRoute.value)
const serverId = ref(getRouteParam(managedRoute.value.params.id) ?? '')
watch(
router.currentRoute,
(nextRoute) => {
if (!nextRoute.path.startsWith('/hosting/manage/')) return
managedRoute.value = nextRoute
serverId.value = getRouteParam(nextRoute.params.id) ?? ''
},
{ immediate: true },
)
function getRouteParam(param: string | string[] | undefined): string | null {
if (Array.isArray(param)) return param[0] ?? null
return param ?? null
}
if (serverId.value) {
try {
@@ -93,7 +103,7 @@ watch(
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
link: `/hosting/manage/${serverId.value}/instances`,
})
}
},
@@ -0,0 +1,16 @@
<template>
<ServersManageInstanceRootLayout>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense>
<component :is="Component" />
</Suspense>
</template>
</RouterView>
</ServersManageInstanceRootLayout>
</template>
<script setup lang="ts">
import { ServersManageInstanceRootLayout } from '@modrinth/ui'
import { RouterView } from 'vue-router'
</script>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageInstancesPage } from '@modrinth/ui'
</script>
<template>
<ServersManageInstancesPage />
</template>
@@ -2,6 +2,8 @@ import Backups from './Backups.vue'
import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Instance from './Instance.vue'
import Instances from './Instances.vue'
import Overview from './Overview.vue'
export { Backups, Content, Files, Index, Overview }
export { Backups, Content, Files, Index, Instance, Instances, Overview }
+408 -240
View File
@@ -13,200 +13,57 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<ContentPageHeader>
<template #icon>
<Avatar
:src="icon ? icon : undefined"
:alt="instance.name"
size="64px"
:tint-by="instance.path"
/>
</template>
<template #title>
{{ instance.name }}
</template>
<template #stats>
<PageHeader
:header="instance.name"
:leading="instanceHeaderLeading"
:metadata="instanceHeaderMetadata"
:actions="instanceHeaderActions"
>
<template #metadata-server-details>
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
<div class="flex items-center gap-2 capitalize font-medium">
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-2 font-medium">
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template v-else>
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="minecraftServer?.region || ping"
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
<ServerPing v-if="ping" :ping="ping" />
</template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="
[
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
].includes(instance.install_stage)
"
color="brand"
size="large"
>
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button :disabled="stopping" @click="stopInstance('InstancePage')">
<StopCircleIcon />
{{ stopping ? 'Stopping...' : 'Stop' }}
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false && !isServerInstance"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<div
v-else-if="playing === false && loading === false && isServerInstance"
class="joined-buttons"
>
<ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled color="brand" size="large">
<OverflowMenu
:options="[
{
id: 'join_server',
action: () => handlePlayServer(),
},
{
id: 'launch_instance',
action: () => startInstance('InstancePage'),
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<template #join_server>
<PlayIcon />
Join server
</template>
<template #launch_instance>
<PlayIcon />
Launch instance
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<button disabled>Starting...</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular size="large">
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => {
if (instance) showProfileInFolder(instance.path)
},
},
{
id: 'export-mrpack',
action: () => exportModal?.show(),
},
]"
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div
v-if="minecraftServer?.region || ping"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
{{ linkedProjectV3.name }}
</router-link>
</div>
</div>
</template>
</ContentPageHeader>
</PageHeader>
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
@@ -218,7 +75,11 @@
:key="instance.path"
>
<template v-if="Component">
<Suspense :key="instance.path">
<Suspense
:key="instance.path"
@pending="subpagePending = true"
@resolve="subpagePending = false"
>
<component
:is="Component"
:instance="instance"
@@ -228,6 +89,7 @@
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
v-bind="contentSubpageProps"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -265,58 +127,64 @@ import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
DropdownIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ContentPageHeader,
formatLoaderLabel,
injectNotificationManager,
NavTabs,
OverflowMenu,
PageHeader,
LoaderIcon as ServerLoaderIcon,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
} from '@modrinth/ui'
import type { Loaders } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, onUnmounted, ref } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter, type LocationQuery } from 'vue-router'
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'
import { process_listener, profile_listener } from '@/helpers/events'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } 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 } from '@/store/state'
@@ -331,6 +199,7 @@ const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -341,98 +210,221 @@ window.addEventListener('online', () => {
})
const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
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 activeProfilePath = ref<string>()
let fetchInstanceRequestId = 0
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
type InstanceRouteContext = {
name: unknown
path: string
query: LocationQuery
}
type InstancePageData = {
profilePath: string
instance?: GameInstance
linkedProjectV3?: Labrinth.Projects.v3.Project
isServerInstance: boolean
preloadedContent: InstanceContentData | null
}
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
liveServerStatusOnline.value = true
loadingServerPing.value = true
}
function isContentSubpageRoute(routeName: unknown = route.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
function resetServerStatus() {
ping.value = undefined
playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.value = false
}
instance.value = await get(route.params.id as string).catch(handleError)
function isCurrentInstanceRequest(requestId: number, profilePath: string) {
return (
requestId === fetchInstanceRequestId &&
route.path.startsWith('/instance') &&
route.params.id === profilePath
)
}
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
function setInstanceBreadcrumbs(nextInstance: GameInstance, routeContext: InstanceRouteContext) {
breadcrumbs.setName(
'Instance',
nextInstance.name.length > 40 ? nextInstance.name.substring(0, 40) + '...' : nextInstance.name,
)
breadcrumbs.setContext({
name: nextInstance.name,
link: routeContext.path,
query: routeContext.query,
})
}
async function loadInstancePageData(
profilePath: string,
routeName: unknown = route.name,
): Promise<InstancePageData> {
const nextInstance = await get(profilePath).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute(routeName)
? loadInstanceContentData(nextInstance.path, undefined, handleError)
: Promise.resolve(null)
if (!offline.value && nextInstance?.linked_data && nextInstance.linked_data.project_id) {
try {
linkedProjectV3.value = await get_project_v3(
instance.value.linked_data.project_id,
nextLinkedProjectV3 = await get_project_v3(
nextInstance.linked_data.project_id,
'must_revalidate',
)
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
if (nextLinkedProjectV3?.minecraft_server != null) {
nextIsServerInstance = true
}
} catch (error) {
handleError(error as Error)
}
}
fetchDeferredData()
const nextPreloadedContent = await contentPreloadPromise
if (instance.value) {
return {
profilePath,
instance: nextInstance ?? undefined,
linkedProjectV3: nextLinkedProjectV3,
isServerInstance: nextIsServerInstance,
preloadedContent: nextPreloadedContent,
}
}
function applyInstancePageData(data: InstancePageData, routeContext: InstanceRouteContext) {
activeProfilePath.value = data.profilePath
resetServerStatus()
playing.value = false
instance.value = data.instance
linkedProjectV3.value = data.linkedProjectV3
isServerInstance.value = data.isServerInstance
preloadedContent.value = data.preloadedContent
if (data.instance) {
setInstanceBreadcrumbs(data.instance, routeContext)
}
fetchDeferredData(data.profilePath)
if (data.instance) {
queryClient.prefetchQuery({
queryKey: ['worlds', instance.value.path],
queryFn: () => refreshWorlds(instance.value!.path),
queryKey: ['worlds', data.instance.path],
queryFn: () => refreshWorlds(data.instance!.path),
staleTime: 30_000,
})
}
}
function fetchDeferredData() {
async function fetchInstance(profilePath = route.params.id as string) {
const requestId = ++fetchInstanceRequestId
const data = await loadInstancePageData(profilePath)
if (!isCurrentInstanceRequest(requestId, profilePath)) return
applyInstancePageData(data, {
name: route.name,
path: route.path,
query: route.query,
})
}
function fetchDeferredData(profilePath: 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 (
activeProfilePath.value !== profilePath ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeProfilePath.value !== profilePath) return
loadingServerPing.value = true
})
} else {
loadingServerPing.value = true
}
updatePlayState()
updatePlayState(profilePath)
}
async function updatePlayState() {
if (!route.params.id) return
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
async function updatePlayState(profilePath = route.params.id as string) {
if (!profilePath) return
const runningProcesses = await get_by_profile_path(profilePath).catch(handleError)
if (activeProfilePath.value !== profilePath) return
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
}
await fetchInstance()
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/instance')) {
await fetchInstance()
}
},
)
await fetchInstance(route.params.id as string)
onBeforeRouteUpdate(async (to) => {
if (!to.path.startsWith('/instance')) return
const profilePath = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id
if (typeof profilePath !== 'string') return false
const requestId = ++fetchInstanceRequestId
const data = await loadInstancePageData(profilePath, to.name)
if (requestId !== fetchInstanceRequestId) return false
applyInstancePageData(data, {
name: to.name,
path: to.path,
query: to.query,
})
})
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
@@ -448,6 +440,9 @@ const renderMode = computed<'scroll' | 'fixed'>(() =>
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const tabs = computed(() => [
{
@@ -472,20 +467,6 @@ const tabs = computed(() => [
},
])
if (instance.value) {
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
}
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
@@ -645,6 +626,16 @@ const timePlayed = computed(() => {
: 0
})
const loaderDisplayName = computed(() =>
instance.value ? (formatLoaderLabel(instance.value.loader) as Loaders) : null,
)
const loaderLabel = computed(() =>
instance.value && loaderDisplayName.value
? [loaderDisplayName.value, instance.value.loader_version].filter(Boolean).join(' ')
: '',
)
const timePlayedHumanized = computed(() => {
const duration = dayjs.duration(timePlayed.value, 'seconds')
const hours = Math.floor(duration.asHours())
@@ -661,6 +652,183 @@ const timePlayedHumanized = computed(() => {
return seconds + ' second' + (seconds > 1 ? 's' : '')
})
const playtimeLabel = computed(() =>
timePlayed.value > 0 ? timePlayedHumanized.value : 'Never played',
)
const instanceHeaderLeading = computed(() => ({
type: 'avatar' as const,
src: icon.value ? icon.value : undefined,
alt: instance.value?.name,
avatarSize: '64px',
tintBy: instance.value?.path,
}))
const instanceHeaderMetadata = computed(() => {
if (!instance.value) return []
if (isServerInstance.value) {
return [
{
id: 'server-details',
type: 'custom' as const,
class: 'contents',
},
]
}
return [
{
id: 'game-version',
label: instance.value.game_version,
icon: Gamepad2Icon,
},
{
id: 'loader',
label: loaderLabel.value,
icon: ServerLoaderIcon,
iconProps: {
loader: loaderDisplayName.value,
},
},
{
id: 'playtime',
label: playtimeLabel.value,
icon: TimerIcon,
},
]
})
const installingStages = [
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
]
const primaryInstanceAction = computed(() => {
if (!instance.value) return null
if (installingStages.includes(instance.value.install_stage)) {
return {
id: 'installing',
label: 'Installing...',
color: 'brand' as const,
disabled: true,
}
}
if (instance.value.install_stage !== 'installed') {
return {
id: 'repair',
label: 'Repair',
icon: DownloadIcon,
color: 'brand' as const,
onClick: () => {
void repairInstance()
},
}
}
if (playing.value === true) {
return {
id: 'stop',
label: stopping.value ? 'Stopping...' : 'Stop',
icon: StopCircleIcon,
color: 'red' as const,
disabled: stopping.value,
onClick: () => {
void stopInstance('InstancePage')
},
}
}
if (playing.value === false && loading.value === false && !isServerInstance.value) {
return {
id: 'play',
label: 'Play',
icon: PlayIcon,
color: 'brand' as const,
onClick: () => {
void startInstance('InstancePage')
},
}
}
if (playing.value === false && loading.value === false && isServerInstance.value) {
return {
id: 'play',
label: 'Play',
color: 'brand' as const,
joinedActions: [
{
id: 'join_server',
label: 'Play',
icon: PlayIcon,
action: () => {
void handlePlayServer()
},
},
{
id: 'launch_instance',
label: 'Launch instance',
icon: PlayIcon,
action: () => {
void startInstance('InstancePage')
},
},
],
}
}
if (loading.value === true && playing.value === false) {
return {
id: 'starting',
label: 'Starting...',
color: 'brand' as const,
disabled: true,
}
}
return null
})
const instanceHeaderActions = computed(() => [
...(primaryInstanceAction.value ? [primaryInstanceAction.value] : []),
{
id: 'settings',
label: 'Instance settings',
icon: SettingsIcon,
labelHidden: true,
tooltip: 'Instance settings',
onClick: () => settingsModal.value?.show(),
},
{
id: 'more',
label: 'More actions',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent' as const,
tooltip: 'More actions',
menuActions: [
{
id: 'open-folder',
label: 'Open folder',
icon: FolderOpenIcon,
action: () => {
if (instance.value) void showProfileInFolder(instance.value.path)
},
},
{
id: 'export-mrpack',
label: 'Export modpack',
icon: PackageIcon,
action: () => exportModal.value?.show(),
},
],
},
])
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
+400 -109
View File
@@ -13,6 +13,7 @@
:modpack-name="linkedModpackProject?.title"
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle"
@@ -87,12 +88,12 @@ import {
ReadyTransition,
useDebugLogger,
useVIntl,
versionChangesGameVersion,
} from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useDebounceFn } from '@vueuse/core'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
@@ -100,22 +101,20 @@ import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
add_project_from_path,
add_project_from_version,
duplicate,
edit,
get,
get_content_items,
get_linked_modpack_content,
get_linked_modpack_info,
list,
remove_project,
toggle_disable_project,
update_managed_modrinth_version,
update_project,
} from '@/helpers/profile'
import { get_categories } from '@/helpers/tags.js'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
@@ -160,6 +159,7 @@ const props = defineProps<{
instance: GameInstance
isServerInstance?: boolean
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const loading = ref(true)
@@ -212,6 +212,7 @@ const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
// TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow.
const updatingProject = ref<ContentItem | null>(null)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
@@ -219,6 +220,82 @@ const loadingChangelog = ref(false)
const updatingModpack = ref(false)
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const activeContentOperationKeys = ref(new Set<string>())
let activeContentOperationCount = 0
let updateRequestId = 0
const activeUpdateRequestId = ref(0)
function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function getContentItemId(item: ContentItem | null | undefined) {
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
}
function getContentOperationKeys(item: ContentItem) {
return [getContentItemId(item), item.file_path, item.file_name].filter(
(key): key is string => !!key,
)
}
function hasContentOperation(item: ContentItem) {
const keys = getContentOperationKeys(item)
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
item.installing = busy
modpackContentModal.value?.updateItem(originalFileName, {
installing: busy,
disabled: busy,
})
if (item.file_name !== originalFileName) {
modpackContentModal.value?.updateItem(item.file_name, {
installing: busy,
disabled: busy,
})
}
}
function beginContentOperation(item: ContentItem) {
if (hasContentOperation(item)) return null
const keys = getContentOperationKeys(item)
activeContentOperationKeys.value = new Set([...activeContentOperationKeys.value, ...keys])
activeContentOperationCount++
isBulkOperating.value = true
setContentItemBusy(item, true)
return { keys, originalFileName: item.file_name }
}
function finishContentOperation(
item: ContentItem,
operation: { keys: string[]; originalFileName: string },
) {
const nextKeys = new Set(activeContentOperationKeys.value)
for (const key of operation.keys) {
nextKeys.delete(key)
}
activeContentOperationKeys.value = nextKeys
activeContentOperationCount = Math.max(0, activeContentOperationCount - 1)
setContentItemBusy(item, false, operation.originalFileName)
if (activeContentOperationCount === 0) {
isBulkOperating.value = false
}
}
function beginUpdateRequest() {
updateRequestId++
activeUpdateRequestId.value = updateRequestId
return updateRequestId
}
function isActiveUpdateRequest(requestId: number) {
return activeUpdateRequestId.value === requestId
}
async function handleBrowseContent() {
if (!props.instance) return
@@ -265,9 +342,21 @@ async function handleUploadFiles() {
}
async function toggleDisableMod(mod: ContentItem) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
try {
mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!)
const newPath = await toggle_disable_project(props.instance.path, mod.file_path)
const newFileName = fileNameFromPath(newPath)
mod.file_path = newPath
mod.file_name = newFileName
mod.enabled = !mod.enabled
modpackContentModal.value?.updateItem(operation.originalFileName, {
file_path: newPath,
file_name: newFileName,
enabled: mod.enabled,
})
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
@@ -279,33 +368,48 @@ async function toggleDisableMod(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
const toggleDisableDebounced = useDebounceFn(toggleDisableMod, 20)
const toggleDisableDebounced = toggleDisableMod
async function removeMod(mod: ContentItem) {
await remove_project(props.instance.path, mod.file_path!).catch(handleError)
projects.value = projects.value.filter((x) => mod.file_path !== x.file_path)
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
try {
const removedPath = mod.file_path
await remove_project(props.instance.path, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
async function updateProject(mod: ContentItem) {
try {
const newPath = await update_project(props.instance.path, mod.file_path!)
mod.file_path = newPath
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
if (mod.update_version_id) {
const versionData = await get_version(mod.update_version_id, 'must_revalidate').catch(
handleError,
)
try {
const updateVersionId = mod.update_version_id
await update_project(props.instance.path, mod.file_path)
if (updateVersionId) {
const versionData = await get_version(updateVersionId, 'must_revalidate').catch(handleError)
if (versionData) {
const profile = await get(props.instance.path).catch(handleError)
@@ -316,12 +420,6 @@ async function updateProject(mod: ContentItem) {
}
}
mod.has_update = false
if (mod.version && mod.update_version_id) {
mod.version.id = mod.update_version_id
}
mod.update_version_id = null
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
@@ -331,39 +429,65 @@ async function updateProject(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
isBulkOperating.value = true
mod.installing = true
if (mod.version) {
mod.version.id = version.id
mod.version.version_number = version.version_number
}
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
const oldPath = mod.file_path
const wasDisabled = mod.enabled === false || oldPath.endsWith('.disabled')
let newPath: string | null = null
let shouldRemoveNewOnError = false
try {
await remove_project(props.instance.path, mod.file_path!)
const newPath = await add_project_from_version(props.instance.path, version.id, 'standalone')
newPath = await add_project_from_version(props.instance.path, version.id, 'update')
shouldRemoveNewOnError = newPath !== oldPath
if (wasDisabled) {
newPath = await toggle_disable_project(props.instance.path, newPath)
}
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, version, 'update').catch(handleError)
}
mod.file_path = newPath
shouldRemoveNewOnError = false
if (newPath !== oldPath) {
await remove_project(props.instance.path, oldPath)
}
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
if (shouldRemoveNewOnError && newPath && newPath !== oldPath) {
await remove_project(props.instance.path, newPath).catch(() => {})
}
handleError(err as Error)
} finally {
mod.installing = false
isBulkOperating.value = false
await initProjects()
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function handleUpdate(id: string) {
const item = projects.value.find((p) => p.id === id)
const item = projects.value.find((p) => getContentItemId(p) === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
debug('handleUpdate triggered', {
fileName: item.file_name,
projectType: item.project_type,
@@ -384,12 +508,48 @@ async function handleUpdate(id: string) {
await nextTick()
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
const initialVersionId = item.update_version_id ?? undefined
debug('handleUpdate: opening content updater modal', {
type: 'content',
initialVersionId,
item: {
id: item.id,
fileName: item.file_name,
projectType: item.project_type,
projectId: item.project.id,
projectTitle: item.project.title,
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
},
instance: {
path: props.instance.path,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = (await get_project_versions(item.project.id).catch((e) => {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (!versions) {
@@ -414,6 +574,25 @@ async function handleUpdate(id: string) {
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleUpdate: resolved content updater preselection', {
type: 'content',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
currentVersionId: item.version.id,
updateVersionId: item.update_version_id,
})
updatingProjectVersions.value = versions
}
@@ -421,6 +600,9 @@ async function handleUpdate(id: string) {
async function handleSwitchVersion(item: ContentItem) {
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
@@ -435,6 +617,9 @@ async function handleSwitchVersion(item: ContentItem) {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (!versions) return
@@ -468,9 +653,28 @@ async function handleModpackContent() {
}
}
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
if (!props.instance?.path) return
const contentItems = await get_linked_modpack_content(props.instance.path, cacheBehaviour).catch(
handleError,
)
if (contentItems) {
modpackContentModal.value?.setItems(contentItems)
}
}
async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
await initProjects(cacheBehaviour)
await refreshModpackContentItems(cacheBehaviour)
}
async function handleModpackUpdate() {
if (!props.instance?.linked_data?.project_id) return
const requestId = beginUpdateRequest()
updatingModpack.value = true
updatingProject.value = null
updatingProjectVersions.value = []
@@ -479,14 +683,42 @@ async function handleModpackUpdate() {
await nextTick()
contentUpdaterModal.value?.show(
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined,
)
const initialVersionId =
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
linkedModpackProject: linkedModpackProject.value,
linkedModpackVersion: linkedModpackVersion.value,
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
instance: {
path: props.instance.path,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
handleError,
)) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
loadingVersions.value = false
if (!versions) return
@@ -494,6 +726,25 @@ async function handleModpackUpdate() {
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleModpackUpdate: resolved modpack updater preselection', {
type: 'modpack',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: props.instance.linked_data.version_id,
})
updatingProjectVersions.value = versions
}
@@ -502,10 +753,12 @@ async function fetchAndSpliceVersion(
versionId: string,
cacheBehaviour?: Parameters<typeof get_version>[1],
onError?: (err: unknown) => void,
requestId = activeUpdateRequestId.value,
) {
const fullVersion = (await get_version(versionId, cacheBehaviour).catch(
onError ?? (() => null),
)) as Labrinth.Versions.v2.Version | null
if (!isActiveUpdateRequest(requestId)) return
if (!fullVersion) return
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
if (index !== -1) {
@@ -517,17 +770,26 @@ async function fetchAndSpliceVersion(
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
const requestId = activeUpdateRequestId.value
loadingChangelog.value = true
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError as (err: unknown) => void)
loadingChangelog.value = false
await fetchAndSpliceVersion(
version.id,
'must_revalidate',
handleError as (err: unknown) => void,
requestId,
)
if (isActiveUpdateRequest(requestId)) {
loadingChangelog.value = false
}
}
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
await fetchAndSpliceVersion(version.id)
await fetchAndSpliceVersion(version.id, undefined, undefined, activeUpdateRequestId.value)
}
function resetUpdateState() {
activeUpdateRequestId.value = 0
updatingModpack.value = false
updatingProject.value = null
updatingProjectVersions.value = []
@@ -535,13 +797,23 @@ function resetUpdateState() {
loadingChangelog.value = false
}
function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = props.instance?.linked_data?.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
if (!shouldShowWarning) {
await handleModpackUpdateConfirm()
return
}
modpackUpdateConfirmModal.value?.show()
}
@@ -551,6 +823,7 @@ async function handleModpackUpdateConfirm() {
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(props.instance.path, version.id)
@@ -574,7 +847,7 @@ async function handleModalUpdate(
pendingModpackUpdateVersion.value = selectedVersion
await handleModpackUpdateConfirm()
} else {
handleModpackUpdateRequest(selectedVersion)
await handleModpackUpdateRequest(selectedVersion)
}
} else if (updatingProject.value) {
const mod = updatingProject.value
@@ -662,51 +935,31 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (!props.instance) return
const [contentItems, modpackInfo, allCategories] = await Promise.all([
get_content_items(props.instance.path, cacheBehaviour).catch(handleError),
get_linked_modpack_info(props.instance.path, cacheBehaviour).catch(handleError),
get_categories().catch(handleError),
])
const contentData = await loadInstanceContentData(
props.instance.path,
cacheBehaviour,
handleError,
)
applyContentData(contentData)
}
if (!contentItems) {
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== props.instance.path) return false
if (!contentData.contentItems) {
loading.value = false
return
return true
}
projects.value = contentItems
projects.value = contentData.contentItems
if (modpackInfo) {
linkedModpackProject.value = {
...modpackInfo.project,
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
}
linkedModpackVersion.value = {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
}
linkedModpackOwner.value = modpackInfo.owner
? {
...modpackInfo.owner,
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
}
: null
linkedModpackHasUpdate.value = modpackInfo.has_update
linkedModpackUpdateVersionId.value = modpackInfo.update_version_id
if (allCategories && modpackInfo.project.categories) {
const seen = new Set<string>()
linkedModpackCategories.value = allCategories.filter((cat: { name: string }) => {
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
seen.add(cat.name)
return true
}
return false
})
} else {
linkedModpackCategories.value = []
}
if (contentData.modpack) {
linkedModpackProject.value = contentData.modpack.project
linkedModpackVersion.value = contentData.modpack.version
linkedModpackOwner.value = contentData.modpack.owner
linkedModpackCategories.value = contentData.modpack.categories
linkedModpackHasUpdate.value = contentData.modpack.hasUpdate
linkedModpackUpdateVersionId.value = contentData.modpack.updateVersionId
} else {
linkedModpackProject.value = null
linkedModpackVersion.value = null
@@ -717,6 +970,7 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
}
loading.value = false
return true
}
provideAppBackup({
@@ -809,8 +1063,9 @@ provideContentManager({
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
id: item.id,
id: getContentItemId(item),
project: item.project ?? {
id: item.file_name,
slug: null,
@@ -844,10 +1099,22 @@ provideContentManager({
filterPersistKey: props.instance.path,
})
await initProjects()
type UnlistenFn = () => void
const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
function loadInitialContent() {
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
return Promise.resolve()
}
return initProjects()
}
async function restoreModpackContentModalState() {
if (!savedModalState) return
// Restore modpack content modal state if returning via back navigation
if (savedModalState) {
const stateToRestore = savedModalState
savedModalState = null
await nextTick()
@@ -860,18 +1127,32 @@ const removeBeforeEach = router.beforeEach(() => {
savedModalState = state ?? null
})
const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
let isUnmounted = false
let unlistenDragDrop: UnlistenFn | null = null
let unlistenProfiles: UnlistenFn | null = null
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
}
await initProjects()
})
onMounted(() => {
void getCurrentWebview()
.onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
}
await initProjects()
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenDragDrop = unlisten
})
.catch(handleError)
void profile_listener(async (event: { event: string; profile_path_id: string }) => {
if (
props.instance &&
event.profile_path_id === props.instance.path &&
@@ -881,8 +1162,17 @@ const unlistenProfiles = await profile_listener(
) {
await initProjects()
}
},
)
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenProfiles = unlisten
})
.catch(handleError)
})
watch(
() => props.instance?.install_stage,
@@ -905,8 +1195,9 @@ watch(
)
onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
unlisten()
unlistenProfiles()
unlistenDragDrop?.()
unlistenProfiles?.()
})
</script>
+159 -125
View File
@@ -47,9 +47,9 @@
<div class="flex flex-col gap-4 p-6">
<div
v-if="projectInstallContext"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid border-divider bg-surface-1 px-6 pt-6"
>
<BrowseInstallHeader :install-context="projectInstallContext" />
<BrowseInstallHeader :install-context="projectInstallContext" bottom-padding />
</div>
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
<template v-if="data">
@@ -64,121 +64,9 @@
:project="data"
:project-v3="projectV3"
:ping="serverPing"
:actions="projectHeaderActions"
@contextmenu.prevent.stop="handleRightClick"
>
<template v-if="isServerProject" #actions>
<ButtonStyled v-if="serverPlaying" size="large" color="red">
<button @click="handleStopServer">
<StopCircleIcon />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
:disabled="data && installingServerProjects.includes(data.id)"
@click="handleClickPlay"
>
<PlayIcon />
{{
data && installingServerProjects.includes(data.id)
? formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.playButton)
}}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button
v-tooltip="formatMessage(commonMessages.addServerToInstanceButton)"
@click="handleAddServerToInstance"
>
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/project/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else #actions>
<ButtonStyled size="large" color="brand">
<button
v-tooltip="installButtonTooltip"
:disabled="installButtonDisabled"
@click="install(null)"
>
<SpinnerIcon
v-if="installButtonLoading && !installButtonInstalled"
class="animate-spin"
/>
<DownloadIcon v-else-if="!installButtonInstalled && !serverProjectSelected" />
<CheckIcon v-else />
{{ installButtonLabel }}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'follow',
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'save',
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'open-in-browser',
link: `https://modrinth.com/${data.project_type}/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
/>
<NavTabs
:links="[
{
@@ -266,14 +154,12 @@ import {
} from '@modrinth/assets'
import {
BrowseInstallHeader,
ButtonStyled,
commonMessages,
CreationFlowModal,
defineMessages,
getTargetInstallPreferences,
injectNotificationManager,
NavTabs,
OverflowMenu,
ProjectBackgroundGradient,
ProjectHeader,
ProjectSidebarCompatibility,
@@ -286,6 +172,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'
@@ -295,6 +182,10 @@ import { useRoute, useRouter } from 'vue-router'
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 +204,6 @@ import {
list as listInstances,
} from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerLatency } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
@@ -327,6 +217,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()
@@ -340,6 +231,10 @@ const messages = defineMessages({
id: 'app.project.install-context.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
worldFallbackName: {
id: 'app.project.install-context.world-fallback-name',
defaultMessage: 'Instance',
},
alreadyInstalled: {
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
@@ -370,7 +265,10 @@ const serverStatusOnline = ref(false)
const serverInstancePath = ref(null)
const serverPlaying = ref(false)
const serverSetupModalRef = ref(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
const serverInstallContent = createServerInstallContent({
serverSetupModalRef,
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/project/'),
})
serverInstallContent.watchServerContextChanges()
await serverInstallContent.initServerContext()
@@ -423,7 +321,9 @@ const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
if (serverData) {
return {
name: serverData.name,
name:
serverInstallContent.serverContextWorldName.value ??
formatMessage(messages.worldFallbackName),
loader: serverData.loader ?? '',
gameVersion: serverData.mc_version ?? '',
serverId: serverInstallContent.serverIdQuery.value,
@@ -501,6 +401,131 @@ const installButtonTooltip = computed(() => {
return null
})
const projectHeaderActions = computed(() => {
if (!data.value) return []
if (isServerProject.value) {
return [
serverPlaying.value
? {
id: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
color: 'red',
onClick: handleStopServer,
}
: {
id: 'play',
label:
data.value && installingServerProjects.value.includes(data.value.id)
? formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.playButton),
icon: PlayIcon,
color: 'brand',
disabled: data.value && installingServerProjects.value.includes(data.value.id),
onClick: handleClickPlay,
},
{
id: 'add-server-to-instance',
label: formatMessage(commonMessages.addServerToInstanceButton),
icon: PlusIcon,
labelHidden: true,
tooltip: formatMessage(commonMessages.addServerToInstanceButton),
onClick: handleAddServerToInstance,
},
{
id: 'more',
label: 'More options',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: 'More options',
menuActions: [
{
id: 'open-in-browser',
label: 'Open in browser',
icon: ExternalIcon,
action: () => openUrl(`https://modrinth.com/project/${data.value.slug}`),
},
{
divider: true,
},
{
id: 'report',
label: 'Report',
icon: ReportIcon,
color: 'red',
action: () =>
openUrl(`https://modrinth.com/report?item=project&itemID=${data.value.id}`),
},
],
},
]
}
return [
{
id: 'install',
label: installButtonLabel.value,
icon:
installButtonLoading.value && !installButtonInstalled.value
? SpinnerIcon
: !installButtonInstalled.value && !serverProjectSelected.value
? DownloadIcon
: CheckIcon,
iconClass:
installButtonLoading.value && !installButtonInstalled.value ? 'animate-spin' : undefined,
color: 'brand',
tooltip: installButtonTooltip.value,
disabled: installButtonDisabled.value,
onClick: () => install(null),
},
{
id: 'more',
label: 'More options',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: 'More options',
menuActions: [
{
id: 'follow',
label: 'Follow',
icon: HeartIcon,
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'save',
label: 'Save',
icon: BookmarkIcon,
disabled: true,
tooltip: 'Coming soon',
action: () => {},
},
{
id: 'open-in-browser',
label: 'Open in browser',
icon: ExternalIcon,
action: () =>
openUrl(`https://modrinth.com/${data.value.project_type}/${data.value.slug}`),
},
{
divider: true,
},
{
id: 'report',
label: 'Report',
icon: ReportIcon,
color: 'red',
action: () => openUrl(`https://modrinth.com/report?item=project&itemID=${data.value.id}`),
},
],
},
]
})
const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
@@ -587,10 +612,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)
@@ -8,6 +8,7 @@ export interface InstanceSettingsContext {
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
closeModal?: () => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
@@ -17,7 +17,19 @@ import {
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import {
computed,
type ComputedRef,
nextTick,
onActivated,
onDeactivated,
type Ref,
ref,
shallowRef,
watch,
} from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
type ServerFlowFrom = 'onboarding' | 'reset-server'
@@ -52,6 +64,7 @@ export interface ServerInstallContentContext {
isSetupServerContext: ComputedRef<boolean>
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContextWorldName: ComputedRef<string | null>
serverContentProjectIds: Ref<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number>
@@ -202,16 +215,31 @@ async function getQueuedInstallPlaceholders(
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean
}) {
const { serverSetupModalRef } = opts
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
const browseFrom = computed(() => readQueryString(route.query.from))
const routeInContext = computed(() => opts.isRouteInContext?.(route) ?? true)
const contextQuery = shallowRef(route.query)
watch(
[() => route.fullPath, routeInContext],
() => {
if (routeInContext.value) {
contextQuery.value = route.query
}
},
{ immediate: true },
)
const serverIdQuery = computed(() => readQueryString(contextQuery.value.sid))
const worldIdQuery = computed(() => readQueryString(contextQuery.value.wid))
const browseFrom = computed(() => readQueryString(contextQuery.value.from))
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
? browseFrom.value
@@ -224,11 +252,13 @@ export function createServerInstallContent(opts: {
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContextServerFull = ref<Archon.Servers.v1.ServerFull | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const serverContentInstallKeys = ref<Set<string>>(new Set())
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
new Map(),
)
const componentActive = ref(true)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
@@ -241,6 +271,18 @@ export function createServerInstallContent(opts: {
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
const serverContextWorld = computed(() => {
const serverFull = serverContextServerFull.value
if (!serverFull) return null
const worldId = effectiveServerWorldId.value
if (worldId) {
return serverFull.worlds.find((world) => world.id === worldId) ?? null
}
return serverFull.worlds.find((world) => world.is_active) ?? serverFull.worlds[0] ?? null
})
const serverContextWorldName = computed(() => serverContextWorld.value?.name ?? null)
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
@@ -250,7 +292,7 @@ export function createServerInstallContent(opts: {
if (serverFlowFrom.value === 'reset-server') {
return `/hosting/manage/${sid}?openSettings=installation`
}
return `/hosting/manage/${sid}/content`
return getServerInstanceContentPath(sid, effectiveServerWorldId.value)
})
const serverBackLabel = computed(() => {
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
@@ -264,9 +306,27 @@ export function createServerInstallContent(opts: {
return 'Installing content'
})
onActivated(() => {
componentActive.value = true
})
onDeactivated(() => {
componentActive.value = false
})
async function getServerContextServerFull(serverId: string) {
if (serverContextServerFull.value?.id === serverId) {
return serverContextServerFull.value
}
const server = await client.archon.servers_v1.get(serverId)
serverContextServerFull.value = server
return server
}
async function resolveServerContextWorldId(serverId: string) {
try {
const server = await client.archon.servers_v1.get(serverId)
const server = await getServerContextServerFull(serverId)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
} catch (err) {
@@ -302,6 +362,11 @@ export function createServerInstallContent(opts: {
} catch (err) {
handleError(err as Error)
}
try {
await getServerContextServerFull(sid)
} catch (err) {
handleError(err as Error)
}
let resolvedWorldId = effectiveServerWorldId.value
if (!resolvedWorldId) {
@@ -318,41 +383,79 @@ export function createServerInstallContent(opts: {
}
function watchServerContextChanges() {
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
if (!sid) {
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
watch(
[componentActive, routeInContext, serverIdQuery, effectiveServerWorldId],
async ([active, inContext, sid, wid], [prevActive, prevInContext, prevSid, prevWid]) => {
if (!active || !inContext) return
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
if (!sid) {
serverContextServerData.value = null
serverContextServerFull.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
return
}
}
if (wid !== prevWid) {
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
}
const hasServerDataForRoute = serverContextServerData.value?.server_id === sid
const hasServerFullForRoute = serverContextServerFull.value?.id === sid
const didEnterContext = !prevActive || !prevInContext
const shouldReloadRouteContext =
didEnterContext ||
sid !== prevSid ||
wid !== prevWid ||
!hasServerDataForRoute ||
!hasServerFullForRoute
if (wid && (sid !== prevSid || wid !== prevWid)) {
await refreshServerInstalledContent(sid, wid)
}
})
if (!hasServerDataForRoute || !hasServerFullForRoute) {
serverContextWorldId.value = worldIdQuery.value
if (!hasServerDataForRoute) {
serverContextServerData.value = null
}
if (!hasServerFullForRoute) {
serverContextServerFull.value = null
}
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
}
if (!hasServerDataForRoute) {
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
}
if (!hasServerFullForRoute) {
try {
const serverFull = await getServerContextServerFull(sid)
if (!worldIdQuery.value) {
const activeWorld = serverFull.worlds.find((world) => world.is_active)
serverContextWorldId.value = activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
}
} catch (err) {
handleError(err as Error)
}
}
if (shouldReloadRouteContext) {
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
}
if (wid && shouldReloadRouteContext) {
await refreshServerInstalledContent(sid, wid)
}
},
)
}
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
if (!routeInContext.value || !isSetupServerContext.value || currentProjectType === 'modpack')
return
router.replace({
path: '/browse/modpack',
query: route.query,
query: contextQuery.value,
})
}
@@ -422,7 +525,7 @@ export function createServerInstallContent(opts: {
if (isInstallingQueuedServerInstalls.value) return false
if (!serverId || !worldId) {
handleError(new Error('No server world is available for install.'))
handleError(new Error('No server instance is available for install.'))
return false
}
@@ -471,6 +574,9 @@ export function createServerInstallContent(opts: {
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
}
return true
} finally {
@@ -491,6 +597,7 @@ export function createServerInstallContent(opts: {
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
@@ -550,7 +657,7 @@ export function createServerInstallContent(opts: {
if (serverFlowFrom.value === 'onboarding') {
await client.archon.servers_v1.endIntro(sid)
await router.push(`/hosting/manage/${sid}/content`)
await router.push(getServerInstanceContentPath(sid, wid))
return
}
@@ -565,6 +672,11 @@ export function createServerInstallContent(opts: {
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
}
function getServerInstanceContentPath(serverId: string, worldId: string | null) {
const base = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
return worldId ? `${base}/${encodeURIComponent(worldId)}` : base
}
return {
serverIdQuery,
worldIdQuery,
@@ -575,6 +687,7 @@ export function createServerInstallContent(opts: {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContextWorldName,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
+58
View File
@@ -6,6 +6,11 @@ import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library'
import * as Project from '@/pages/project'
function getQueryParam(value) {
if (Array.isArray(value)) return value[0] ?? ''
return value ?? ''
}
/**
* Configures application routing. Add page to pages/index and then add to route table here.
*/
@@ -57,6 +62,48 @@ export default new createRouter({
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'instances',
name: 'ServerManageInstances',
component: Hosting.Instances,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'instances/:instance_id',
name: 'ServerManageInstance',
component: Hosting.Instance,
meta: {
breadcrumb: [{ name: '?Server' }],
},
children: [
{
path: '',
name: 'ServerManageInstanceContent',
component: Hosting.Content,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'files',
name: 'ServerManageInstanceFiles',
component: Hosting.Files,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'backups',
name: 'ServerManageInstanceBackups',
component: Hosting.Backups,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{
path: 'files',
name: 'ServerManageFiles',
@@ -81,6 +128,17 @@ export default new createRouter({
component: Pages.Browse,
meta: {
useContext: true,
keepAliveComponent: 'Browse',
keepAliveKey: (route) => {
return [
'browse',
getQueryParam(route.params.projectType),
getQueryParam(route.query.i),
getQueryParam(route.query.sid),
getQueryParam(route.query.wid),
getQueryParam(route.query.from),
].join(':')
},
breadcrumb: [{ name: '?BrowseTitle' }],
},
},
+1
View File
@@ -19,6 +19,7 @@
"types": ["vite/client"],
"paths": {
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
"@/*": ["./src/*"]
}
},
+5
View File
@@ -8,6 +8,7 @@ import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname)
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
const apiClientSource = resolve(projectRootDir, '../../packages/api-client/src/index.ts')
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
const envFilePath = resolve(appLibEnvDir, '.env')
@@ -37,6 +38,10 @@ export default defineConfig({
},
resolve: {
alias: [
{
find: '@modrinth/api-client',
replacement: apiClientSource,
},
{
find: '@',
replacement: resolve(projectRootDir, 'src'),
+7
View File
@@ -51,6 +51,13 @@ tauri-build = { workspace = true, features = ["codegen"] }
[target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation.workspace = true
core-graphics.workspace = true
objc2-app-kit = { workspace = true, features = [
"NSWindow",
] }
[target.'cfg(windows)'.dependencies]
webview2-com.workspace = true
windows = { workspace = true, features = [
+50 -18
View File
@@ -17,6 +17,8 @@ pub struct AdsState {
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
const ADS_USER_AGENT: &str = concat!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ",
@@ -92,10 +94,12 @@ fn configure_ads_cookie_settings(
}
}
fn set_webview_visible<R: Runtime>(
webview: &tauri::Webview<R>,
_visible: bool,
) {
fn set_webview_visible<R: Runtime>(webview: &tauri::Webview<R>, visible: bool) {
#[cfg(not(any(windows, target_os = "macos")))]
{
_ = visible;
}
webview
.with_webview(
#[allow(unused_variables)]
@@ -103,11 +107,18 @@ fn set_webview_visible<R: Runtime>(
#[cfg(windows)]
{
let controller = wv.controller();
unsafe { controller.SetIsVisible(_visible) }.ok();
unsafe { controller.SetIsVisible(visible) }.ok();
}
},
)
.ok();
#[cfg(target_os = "macos")]
if visible {
webview.show().ok();
} else {
webview.hide().ok();
}
}
fn set_webview_visible_for_window<R: Runtime>(
@@ -129,27 +140,48 @@ fn set_webview_visible_for_window<R: Runtime>(
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
fn compute_ads_webview_occlusion<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> Option<bool> {
let main_window = app.get_window("main")?;
let webviews = app.webviews();
let webview = webviews.get("ads-window")?;
let position = webview.position().ok()?;
let size = webview.size().ok()?;
let hwnd = main_window.hwnd().ok()?;
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
hwnd,
position.x,
position.y,
size.width,
size.height,
))
#[cfg(target_os = "macos")]
{
let position = webview.position().ok()?;
let size = webview.size().ok()?;
Some(
crate::api::ads_occlusion_macos::is_ads_webview_occluded(
&main_window,
position.x,
position.y,
size.width,
size.height,
)
.unwrap_or(false),
)
}
#[cfg(windows)]
{
let position = webview.position().ok()?;
let size = webview.size().ok()?;
let hwnd = main_window.hwnd().ok()?;
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
hwnd,
position.x,
position.y,
size.width,
size.height,
))
}
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
let Some(occluded) = compute_ads_webview_occlusion(app) else {
return;
@@ -281,7 +313,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
});
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
{
let app_handle = app.clone();
+236
View File
@@ -0,0 +1,236 @@
use std::ptr::NonNull;
use core_foundation::array::CFArray;
use core_foundation::base::{CFType, TCFType};
use core_foundation::dictionary::CFDictionary;
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use core_graphics::display::CGDisplay;
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use core_graphics::window::{
CGWindowID, kCGWindowAlpha, kCGWindowBounds, kCGWindowLayer,
kCGWindowListExcludeDesktopElements,
kCGWindowListOptionOnScreenAboveWindow, kCGWindowListOptionOnScreenOnly,
kCGWindowNumber, kCGWindowOwnerName, kCGWindowOwnerPID,
};
use objc2_app_kit::NSWindow;
pub fn is_ads_webview_occluded<R: tauri::Runtime>(
main_window: &tauri::Window<R>,
ad_x: i32,
ad_y: i32,
ad_width: u32,
ad_height: u32,
) -> Option<bool> {
let scale_factor = main_window.scale_factor().ok()?;
let maybe_ns_window = main_window.ns_window().ok()?;
let ns_window = NonNull::new(maybe_ns_window)?.cast::<NSWindow>();
let ns_window = unsafe { ns_window.as_ref() };
let window_number = ns_window.windowNumber();
let main_window_id = if window_number > 0 {
window_number as CGWindowID
} else {
return None;
};
let window_infos = CGDisplay::window_list_info(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
None,
)?;
let window_infos = unsafe {
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
window_infos.as_concrete_TypeRef(),
)
};
let main_window_rect = window_infos
.iter()
.find(|window_info| window_id(window_info) == Some(main_window_id))
.and_then(|window_info| window_rect(&window_info))?;
let ad_rect = ad_rect_from_main_window(
&main_window_rect,
ad_x,
ad_y,
ad_width,
ad_height,
scale_factor,
);
if is_empty_rect(&ad_rect) {
return None;
}
let ad_area = rect_area(&ad_rect);
if ad_area == 0.0 {
return None;
}
let app_process_id = std::process::id() as i32;
let windows_above_main = CGDisplay::window_list_info(
kCGWindowListOptionOnScreenAboveWindow
| kCGWindowListExcludeDesktopElements,
Some(main_window_id),
)?;
let windows_above_main = unsafe {
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
windows_above_main.as_concrete_TypeRef(),
)
};
let mut occluded_area = 0.0;
for window_info in windows_above_main.iter() {
if window_id(&window_info) == Some(main_window_id) {
continue;
}
if window_process_id(&window_info) == Some(app_process_id) {
continue;
}
let owner_name = window_owner_name(&window_info);
if owner_name.as_deref().is_some_and(is_system_window_owner) {
continue;
}
let layer = window_layer(&window_info);
if layer != Some(0) {
continue;
}
let alpha = window_alpha(&window_info);
if alpha.is_some_and(|alpha| alpha <= 0.0) {
continue;
}
let Some(rect) = window_rect(&window_info) else {
continue;
};
let Some(intersection) = intersect_rects(&ad_rect, &rect) else {
continue;
};
occluded_area += rect_area(&intersection);
let occluded_ratio = occluded_area / ad_area;
if occluded_ratio >= super::ads::OCCLUDED_AREA_THRESHOLD {
return Some(true);
}
}
Some(false)
}
fn ad_rect_from_main_window(
main_window_rect: &CGRect,
x: i32,
y: i32,
width: u32,
height: u32,
scale_factor: f64,
) -> CGRect {
let scale_factor = if scale_factor > 0.0 {
scale_factor
} else {
1.0
};
CGRect::new(
&CGPoint::new(
main_window_rect.origin.x + x as f64 / scale_factor,
main_window_rect.origin.y + y as f64 / scale_factor,
),
&CGSize::new(width as f64 / scale_factor, height as f64 / scale_factor),
)
}
fn window_id(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<CGWindowID> {
number_value(window_info, unsafe { kCGWindowNumber })
.and_then(|value| u32::try_from(value).ok())
}
fn window_process_id(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<i32> {
number_value(window_info, unsafe { kCGWindowOwnerPID })
.and_then(|value| i32::try_from(value).ok())
}
fn window_layer(window_info: &CFDictionary<CFString, CFType>) -> Option<i32> {
number_value(window_info, unsafe { kCGWindowLayer })
.and_then(|value| i32::try_from(value).ok())
}
fn window_alpha(window_info: &CFDictionary<CFString, CFType>) -> Option<f64> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowAlpha) };
window_info.find(&key)?.downcast::<CFNumber>()?.to_f64()
}
fn window_owner_name(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<String> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowOwnerName) };
Some(window_info.find(&key)?.downcast::<CFString>()?.to_string())
}
fn is_system_window_owner(owner_name: &str) -> bool {
matches!(owner_name, "WindowManager")
}
fn number_value(
window_info: &CFDictionary<CFString, CFType>,
key: core_foundation::string::CFStringRef,
) -> Option<i64> {
let key = unsafe { CFString::wrap_under_get_rule(key) };
window_info.find(&key)?.downcast::<CFNumber>()?.to_i64()
}
fn window_rect(window_info: &CFDictionary<CFString, CFType>) -> Option<CGRect> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowBounds) };
let bounds = window_info.find(&key)?.downcast::<CFDictionary>()?;
let rect = CGRect::from_dict_representation(&bounds)?;
if is_empty_rect(&rect) {
None
} else {
Some(rect)
}
}
fn is_empty_rect(rect: &CGRect) -> bool {
rect.size.width <= 0.0 || rect.size.height <= 0.0
}
fn rect_area(rect: &CGRect) -> f64 {
if is_empty_rect(rect) {
return 0.0;
}
rect.size.width * rect.size.height
}
fn intersect_rects(a: &CGRect, b: &CGRect) -> Option<CGRect> {
let left = a.origin.x.max(b.origin.x);
let top = a.origin.y.max(b.origin.y);
let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
let rect = CGRect::new(
&CGPoint::new(left, top),
&CGSize::new(right - left, bottom - top),
);
if is_empty_rect(&rect) {
None
} else {
Some(rect)
}
}
+1 -3
View File
@@ -8,8 +8,6 @@ use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsIconic, IsWindowVisible,
};
const OCCLUDED_AREA_THRESHOLD: f64 = 1.0;
pub fn is_ads_webview_occluded(
main_hwnd: HWND,
x: i32,
@@ -63,7 +61,7 @@ pub fn is_ads_webview_occluded(
occluded_area.saturating_add(rect_area(&intersection));
if (occluded_area as f64 / ad_area as f64)
>= OCCLUDED_AREA_THRESHOLD
>= super::ads::OCCLUDED_AREA_THRESHOLD
{
return true;
}
+2
View File
@@ -18,6 +18,8 @@ pub mod tags;
pub mod utils;
pub mod ads;
#[cfg(target_os = "macos")]
mod ads_occlusion_macos;
#[cfg(windows)]
mod ads_occlusion_windows;
pub mod cache;
+12 -1
View File
@@ -1,10 +1,15 @@
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
import serverSidedVue from '@vitejs/plugin-vue'
import fs from 'fs/promises'
import { defineNuxtConfig } from 'nuxt/config'
import { fileURLToPath } from 'url'
import svgLoader from 'vite-svg-loader'
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const API_CLIENT_SOURCE = fileURLToPath(
new URL('../../packages/api-client/src/index.ts', import.meta.url),
)
const preloadedFonts = [
'inter/Inter-Regular.woff2',
@@ -24,6 +29,9 @@ const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
export default defineNuxtConfig({
srcDir: 'src/',
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
app: {
head: {
htmlAttrs: {
@@ -82,6 +90,9 @@ export default defineNuxtConfig({
},
cacheDir: '../../node_modules/.vite/apps/knossos',
resolve: {
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
dedupe: ['vue'],
},
plugins: [
-1
View File
@@ -38,7 +38,6 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0",
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
@@ -0,0 +1,127 @@
<template>
<ButtonStyled size="large" circular>
<PopoutMenu
v-if="authUser"
:tooltip="
saved ? formatMessage(commonMessages.savedLabel) : formatMessage(commonMessages.saveButton)
"
from="top-right"
:aria-label="formatMessage(commonMessages.saveButton)"
:dropdown-id="`${baseId}-save`"
>
<BookmarkIcon aria-hidden="true" :fill="saved ? 'currentColor' : 'none'" />
<template #menu>
<StyledInput
v-model="displayCollectionsSearch"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
wrapper-class="menu-search"
/>
<div v-if="filteredCollections.length > 0" class="collections-list text-primary">
<Checkbox
v-for="option in filteredCollections"
:key="option.id"
:model-value="option.projects.includes(projectId)"
class="popout-checkbox"
@update:model-value="() => collectProject(option, projectId)"
>
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">{{ noCollectionsLabel }}</p>
</div>
<ButtonStyled>
<button class="mx-3 mb-3" @click="createCollection">
<PlusIcon aria-hidden="true" />
{{ createNewCollectionLabel }}
</button>
</ButtonStyled>
</template>
</PopoutMenu>
<nuxt-link
v-else
v-tooltip="formatMessage(commonMessages.saveButton)"
:to="signInRoute"
:aria-label="formatMessage(commonMessages.saveButton)"
>
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
</template>
<script setup lang="ts">
import { BookmarkIcon, PlusIcon } from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
PopoutMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
type CollectionOption = {
id: string
name: string
projects: string[]
}
const props = defineProps<{
authUser?: unknown
signInRoute: RouteLocationRaw
projectId: string
collections: CollectionOption[]
saved: boolean
baseId: string
noCollectionsLabel: string
createNewCollectionLabel: string
collectProject: (option: CollectionOption, projectId: string) => void | Promise<void>
createCollection: (event: MouseEvent) => void
}>()
const { formatMessage } = useVIntl()
const displayCollectionsSearch = ref('')
const filteredCollections = computed(() =>
props.collections
.filter((collection) =>
collection.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
)
.slice()
.sort((a, b) => a.name.localeCompare(b.name)),
)
</script>
<style scoped lang="scss">
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
white-space: nowrap;
&:hover {
filter: brightness(0.95);
}
}
.menu-text {
padding: 0 var(--gap-md);
font-size: var(--font-size-nm);
color: var(--color-secondary);
}
.menu-search {
margin: var(--gap-sm) var(--gap-md);
width: calc(100% - var(--gap-md) * 2);
}
.collections-list {
max-height: 40rem;
overflow-y: auto;
background-color: var(--color-bg);
border-radius: var(--radius-md);
margin: var(--gap-sm) var(--gap-md);
padding: var(--gap-sm);
}
</style>
@@ -46,7 +46,7 @@
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<span class="font-semibold text-contrast"> Version changelog </span>
<div class="w-full">
<MarkdownEditor
@@ -131,10 +131,9 @@
class="h-10"
/>
<StyledInput
<DatePicker
v-else-if="field.type === 'date'"
v-model="formData[field.name]"
type="date"
wrapper-class="w-full"
/>
@@ -230,6 +229,7 @@ import {
Admonition,
Checkbox,
Combobox,
DatePicker,
defineMessages,
financialMessages,
formFieldLabels,
@@ -95,11 +95,11 @@
<span class="text-red">*</span>
</span>
</label>
<StyledInput
<DatePicker
v-model="formData.dateOfBirth"
type="date"
:max="maxDate"
:max-date="maxDate"
autocomplete="bday"
placeholder="Select date of birth"
wrapper-class="w-full"
/>
</div>
@@ -213,6 +213,7 @@
import {
Chips,
Combobox,
DatePicker,
defineMessages,
formFieldLabels,
formFieldPlaceholders,
@@ -48,6 +48,16 @@
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="text-sm" />
</div>
<div
v-if="showExternalDependencies"
v-tooltip="'External dependencies'"
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<FileIcon aria-hidden="true" class="size-4 text-secondary" />
<span class="text-sm font-medium text-secondary">
{{ queueEntry.external_dependencies_count }}
</span>
</div>
</div>
<div v-if="queueEntry.ownership?.kind === 'user'">
<NuxtLink
@@ -119,7 +129,7 @@
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import { ClipboardCopyIcon, FileIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
@@ -151,6 +161,7 @@ const formatDateTimeFull = useFormatDateTime({
const props = defineProps<{
queueEntry: ModerationProject
showExternalDependencies?: boolean
}>()
const emit = defineEmits<{
@@ -701,11 +701,11 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
// Quick re-check if close to expiry (last 5 seconds of TTL)
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
const recheck = await moderationQueue.checkLock(next.projectId)
if (recheck.locked && !recheck.expired) {
// Project got locked, remove from queue and try next
const recheckResults = await batchCheckQueueCandidates([next.projectId])
const recheck = recheckResults.get(next.projectId)
if (!isEligibleQueueCandidate(recheck)) {
prefetchQueue.value.shift()
return navigateToNextUnlockedProject() // Recurse to try next
return navigateToNextUnlockedProject()
}
}
@@ -717,33 +717,14 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
if (next.skippedIds.length > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${next.skippedIds.length} project(s) being moderated by others.`,
type: 'info',
})
}
notifySkippedQueueProjects(next.skippedIds.length)
// Trigger prefetch replenishment in background (don't await)
maintainPrefetchQueue()
// Navigate to canonical URL if we have metadata (avoids middleware redirect)
if (next.slug && next.projectType) {
const urlType = getProjectTypeForUrlShorthand(next.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${next.slug}`,
state: { showChecklist: true },
})
} else {
// Fallback: use project ID (will trigger middleware redirect)
navigateTo({
name: 'type-project',
params: { type: 'project', project: next.projectId },
state: { showChecklist: true },
})
}
navigateToQueueProject(
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
next.projectId,
)
return true
}
@@ -869,53 +850,107 @@ function reviewAnyway() {
maintainPrefetchQueue()
}
// Batch check locks and fetch project metadata in parallel
interface LockCheckResult {
// Batch check locks, processing status, and fetch project metadata in parallel
interface QueueCandidateCheck {
locked: boolean
expired?: boolean
isOwnLock?: boolean
slug?: string
projectType?: string
status?: string
isProcessing: boolean
}
async function batchCheckLocksWithMetadata(
function isEligibleQueueCandidate(result: QueueCandidateCheck | undefined): boolean {
if (!result?.isProcessing) return false
return !result.locked || !!result.expired || !!result.isOwnLock
}
function notifySkippedQueueProjects(count: number) {
if (count <= 0) return
addNotification({
title: 'Skipped projects',
text: `Skipped ${count} project(s) already moderated or locked by others.`,
type: 'info',
})
}
function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) {
if (result.slug && result.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${result.slug}`,
state: { showChecklist: true },
})
} else {
navigateTo({
name: 'type-project',
params: { type: 'project', project: projectId },
state: { showChecklist: true },
})
}
}
async function batchCheckQueueCandidates(
projectIds: string[],
): Promise<Map<string, LockCheckResult>> {
const results = new Map<string, LockCheckResult>()
): Promise<Map<string, QueueCandidateCheck>> {
const results = new Map<string, QueueCandidateCheck>()
// Check locks and fetch minimal project data in parallel
const checks = await Promise.allSettled(
projectIds.map(async (id) => {
// Parallel: check lock AND fetch project metadata
const [lockResponse, projectData] = await Promise.all([
moderationQueue.checkLock(id),
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
])
const status = (projectData as { status?: string } | null)?.status
return {
id,
locked: lockResponse.locked,
expired: lockResponse.expired,
isOwnLock: lockResponse.is_own_lock,
slug: (projectData as { slug?: string })?.slug,
projectType: (projectData as { project_type?: string })?.project_type,
slug: (projectData as { slug?: string } | null)?.slug,
projectType: (projectData as { project_type?: string } | null)?.project_type,
status,
isProcessing: projectData === null ? true : status === 'processing',
}
}),
)
// Use forEach with index to avoid indexOf bug on PromiseSettledResult
checks.forEach((result, index) => {
if (result.status === 'fulfilled') {
results.set(result.value.id, result.value)
} else {
// On error, mark as needing fallback (no metadata)
results.set(projectIds[index], { locked: false })
results.set(projectIds[index], { locked: false, isProcessing: true })
}
})
return results
}
async function findNextEligibleQueueProject(candidateIds: string[]) {
const skippedIds: string[] = []
let checkedCount = 0
while (checkedCount < candidateIds.length) {
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
checkedCount += batch.length
const results = await batchCheckQueueCandidates(batch)
for (const id of batch) {
const result = results.get(id)
if (isEligibleQueueCandidate(result)) {
return { projectId: id, result: result!, skippedIds: [...skippedIds] }
}
skippedIds.push(id)
}
}
return null
}
// Maintain a queue of prefetched unlocked projects for instant navigation
async function maintainPrefetchQueue() {
if (isPrefetching.value) return
@@ -947,13 +982,10 @@ async function maintainPrefetchQueue() {
const remainingItems =
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
const candidateIds = remainingItems
.filter((id) => !prefetchedIds.has(id))
.slice(0, PREFETCH_BATCH_SIZE * 2) // Check up to 10 candidates
const candidateIds = remainingItems.filter((id) => !prefetchedIds.has(id))
if (candidateIds.length === 0) return
// 5. Batch check locks AND fetch metadata in parallel
const skippedIds: string[] = []
let checkedCount = 0
@@ -964,31 +996,18 @@ async function maintainPrefetchQueue() {
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
checkedCount += batch.length
const results = await batchCheckLocksWithMetadata(batch)
const results = await batchCheckQueueCandidates(batch)
for (const id of batch) {
const result = results.get(id)
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
if (!result?.locked || result?.expired || result?.isOwnLock) {
// Found unlocked project with metadata
if (result?.slug && result?.projectType) {
prefetchQueue.value.push({
projectId: id,
slug: result.slug,
projectType: result.projectType,
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
} else {
// No metadata - still add but will need fallback navigation
prefetchQueue.value.push({
projectId: id,
slug: '', // Empty = use fallback
projectType: '',
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
}
if (isEligibleQueueCandidate(result)) {
prefetchQueue.value.push({
projectId: id,
slug: result?.slug ?? '',
projectType: result?.projectType ?? '',
validatedAt: Date.now(),
skippedIds: [...skippedIds],
})
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
} else {
@@ -1004,8 +1023,6 @@ async function maintainPrefetchQueue() {
// Debounced prefetch to prevent spam from rapid stage changes
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
const MAX_SKIP_ATTEMPTS = 10
async function skipToNextProject() {
// Skip the current project
const currentProjectId = projectV2.value?.id
@@ -1029,60 +1046,28 @@ async function skipToNextProject() {
debug('[skipToNextProject] No prefetch, entering fallback with batch checking')
// Fallback: batch check remaining projects with metadata (excluding current)
const remainingIds: string[] = []
const queueItems = moderationQueue.currentQueue.items
// Build list of remaining projects, excluding current
for (const id of queueItems) {
if (id === currentProjectId) continue
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
remainingIds.push(id)
}
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
if (remainingIds.length > 0) {
const results = await batchCheckLocksWithMetadata(remainingIds)
const next = await findNextEligibleQueueProject(remainingIds)
let skippedCount = 0
for (const id of remainingIds) {
const result = results.get(id)
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
if (!result?.locked || result?.expired || result?.isOwnLock) {
// Found unlocked - skip the locked ones before it
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
// Navigate to canonical URL if we have metadata
if (result?.slug && result?.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${result.slug}`,
state: { showChecklist: true },
})
} else {
// Fallback: use project ID
navigateTo({
name: 'type-project',
params: { type: 'project', project: id },
state: { showChecklist: true },
})
}
return
}
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
if (next) {
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
navigateToQueueProject(next.result, next.projectId)
return
}
// All checked were locked
debug('[skipToNextProject] All projects were locked, skippedCount:', skippedCount)
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
debug('[skipToNextProject] No eligible projects in queue')
addNotification({
title: 'All projects locked',
text: 'All remaining projects are currently being moderated by others.',
title: 'No projects available',
text: 'All remaining projects are already moderated or locked by others.',
type: 'warning',
})
}
@@ -1298,32 +1283,16 @@ onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
notifications.setNotificationLocation('left')
// Check if project has already been reviewed (not in processing status)
if (projectV2.value.status !== 'processing') {
alreadyReviewed.value = true
return
}
// Try to acquire lock
const result = await moderationQueue.acquireLock(projectV2.value.id)
if (result.success) {
handleLockAcquired()
} else if (result.locked_by) {
// Actually locked by another moderator
// In queue mode with more projects - auto-skip to next project
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
addNotification({
title: 'Project locked',
text: `Skipped project locked by @${result.locked_by.username}.`,
type: 'info',
})
// skipToNextProject already calls completeCurrentProject
await skipToNextProject()
return
}
// Single project mode or last in queue - show locked UI
lockStatus.value = {
locked: true,
lockedBy: result.locked_by,
@@ -2085,72 +2054,36 @@ async function endChecklist(status?: string) {
})
}
} else {
// Use prefetched data if available for instant navigation
if (!(await navigateToNextUnlockedProject())) {
// Fallback: batch check remaining projects with metadata
const remainingIds: string[] = []
const currentProjectId = projectV2.value?.id
const queueItems = moderationQueue.currentQueue.items
const remainingIds = moderationQueue.currentQueue.items.filter(
(id) => id !== currentProjectId,
)
// Build list of remaining projects, excluding current
for (const id of queueItems) {
if (id === currentProjectId) continue
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
remainingIds.push(id)
}
let foundUnlocked = false
let foundEligible = false
if (remainingIds.length > 0) {
const results = await batchCheckLocksWithMetadata(remainingIds)
const next = await findNextEligibleQueueProject(remainingIds)
let skippedCount = 0
for (const id of remainingIds) {
const result = results.get(id)
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
if (!result?.locked || result?.expired || result?.isOwnLock) {
// Found unlocked - skip the locked ones before it
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
// Navigate to canonical URL if we have metadata
if (result?.slug && result?.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
navigateTo({
path: `/${urlType}/${result.slug}`,
state: { showChecklist: true },
})
} else {
// Fallback: use project ID
navigateTo({
name: 'type-project',
params: { type: 'project', project: id },
state: { showChecklist: true },
})
}
foundUnlocked = true
break
}
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
}
// If no unlocked projects found, show notification
if (!foundUnlocked && skippedCount > 0) {
if (next) {
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
navigateToQueueProject(next.result, next.projectId)
foundEligible = true
} else {
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
addNotification({
title: 'All projects locked',
text: 'All remaining projects are currently being moderated by others.',
title: 'No projects available',
text: 'All remaining projects are already moderated or locked by others.',
type: 'warning',
})
}
}
// If no unlocked projects found, go back to moderation queue
if (!foundUnlocked) {
if (!foundEligible) {
await navigateTo({
name: 'moderation',
})
@@ -65,7 +65,7 @@ const messages = defineMessages({
},
noServerWorld: {
id: 'discover.install.error.no-server-world',
defaultMessage: 'No server world is available for install.',
defaultMessage: 'No server instance is available for install.',
},
backToSetup: {
id: 'discover.install.back-to-setup',
@@ -83,6 +83,10 @@ const messages = defineMessages({
id: 'discover.install.heading.reset-modpack',
defaultMessage: 'Selecting modpack to install after reset',
},
worldFallbackName: {
id: 'discover.install.world-fallback-name',
defaultMessage: 'Instance',
},
})
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
@@ -145,6 +149,11 @@ export function useServerInstallContent({
return enabled
}),
})
const { data: serverFullData } = useQuery({
queryKey: computed(() => ['servers', 'v1', 'detail', currentServerId.value ?? ''] as const),
queryFn: () => client.archon.servers_v1.get(currentServerId.value!),
enabled: computed(() => !!currentServerId.value),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
@@ -187,7 +196,10 @@ export function useServerInstallContent({
},
}
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const contentQueryKey = computed(
() =>
['content', 'list', 'v1', currentServerId.value ?? '', currentWorldId.value ?? null] as const,
)
const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey,
queryFn: () =>
@@ -625,7 +637,9 @@ export function useServerInstallContent({
if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
navigateTo(
getServerInstanceContentPath(currentServerId.value, currentWorldId.value ?? null),
)
} else {
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
}
@@ -641,9 +655,14 @@ export function useServerInstallContent({
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server')
return `/hosting/manage/${id}?openSettings=installation`
return `/hosting/manage/${id}/content`
return getServerInstanceContentPath(id, currentWorldId.value)
})
function getServerInstanceContentPath(serverId: string, worldId: string | null) {
const base = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
return worldId ? `${base}/${encodeURIComponent(worldId)}` : base
}
const serverBackLabel = computed(() => {
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
@@ -655,13 +674,26 @@ export function useServerInstallContent({
? formatMessage(messages.resetModpackHeading)
: formatMessage(commonMessages.installingContentLabel),
)
const currentWorld = computed(() => {
const full = serverFullData.value
if (!full) return null
const worldId = currentWorldId.value
if (worldId) {
return full.worlds.find((world) => world.id === worldId) ?? null
}
return full.worlds.find((world) => world.is_active) ?? full.worlds[0] ?? null
})
const installContext = computed(() => {
if (!serverData.value) return null
return {
name: serverData.value.name,
loader: serverData.value.loader ?? '',
gameVersion: serverData.value.mc_version ?? '',
name: currentWorld.value?.name ?? formatMessage(messages.worldFallbackName),
loader: currentWorld.value?.content?.modloader ?? serverData.value.loader ?? '',
loaderVersion:
currentWorld.value?.content?.modloader_version ?? serverData.value.loader_version ?? '',
gameVersion: currentWorld.value?.content?.game_version ?? serverData.value.mc_version ?? '',
serverId: currentServerId.value,
upstream: serverData.value.upstream,
iconSrc: serverIcon.value,
+4 -1
View File
@@ -199,17 +199,20 @@ export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipO
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export interface ModerationProject {
project: any
ownership: ModerationOwnership | null
external_dependencies_count: number
}
export function toModerationProjects(projects: ProjectWithOwnership[]): ModerationProject[] {
return projects.map(({ ownership, ...project }) => ({
return projects.map(({ ownership, external_dependencies_count, ...project }) => ({
project,
ownership: ownership ?? null,
external_dependencies_count: external_dependencies_count,
}))
}
+29
View File
@@ -761,9 +761,13 @@ import {
commonMessages,
commonProjectTypeCategoryMessages,
commonSettingsMessages,
createHostingIntercomIdentityKey,
defineMessages,
injectModrinthClient,
injectPageContext,
OverflowMenu,
providePageContext,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue'
@@ -808,6 +812,25 @@ const router = useNativeRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const client = injectModrinthClient()
const pageContext = injectPageContext()
const hostingIntercomActive = computed(() => route.path.startsWith('/hosting') && !!auth.value.user)
const hostingIntercomServerId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : rawId
})
const hostingIntercom = useHostingIntercom({
enabled: hostingIntercomActive,
appId: computed(() => config.public.intercomAppId),
fetchToken: fetchIntercomToken,
identityKey: computed(() =>
createHostingIntercomIdentityKey(auth.value.user, hostingIntercomServerId.value),
),
})
providePageContext({
...pageContext,
intercomBubble: hostingIntercom.intercomBubble,
})
const { data: payoutBalance } = useQuery({
queryKey: ['payout', 'balance'],
@@ -836,6 +859,12 @@ const showTinMismatchBanner = computed(() => {
const basePopoutId = useId()
async function fetchIntercomToken() {
return $fetch('/api/intercom/messenger-jwt', {
query: hostingIntercomServerId.value ? { server_id: hostingIntercomServerId.value } : {},
})
}
const navMenuMessages = defineMessages({
home: {
id: 'layout.nav.home',
+10 -1
View File
@@ -1353,7 +1353,7 @@
"message": "Cancel reset"
},
"discover.install.error.no-server-world": {
"message": "No server world is available for install."
"message": "No server instance is available for install."
},
"discover.install.error.unsupported-content-type": {
"message": "This content type cannot be installed to a server from browse."
@@ -1361,6 +1361,9 @@
"discover.install.heading.reset-modpack": {
"message": "Selecting modpack to install after reset"
},
"discover.install.world-fallback-name": {
"message": "Instance"
},
"discover.seo.description": {
"message": "Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}."
},
@@ -3290,6 +3293,12 @@
"servers.manage.content.title": {
"message": "Content - {serverName} - Modrinth"
},
"servers.manage.instances.meta.title": {
"message": "Instances - {server} - Modrinth"
},
"servers.manage.instances.slot-name": {
"message": "Instance #{index}"
},
"servers.notice.actions": {
"message": "Actions"
},
@@ -20,8 +20,13 @@ 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
// Only handle project routes
if (!to.params.id || !PROJECT_TYPES.includes(to.params.type as string)) {
if (!projectId || !routeType || !PROJECT_TYPES.includes(routeType)) {
return
}
@@ -29,7 +34,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
const projectId = to.params.id as string
try {
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
@@ -0,0 +1,84 @@
import { useAppQueryClient } from '~/composables/query-client'
import { createModrinthClient } from '~/helpers/api.ts'
export default defineNuxtRouteMiddleware(async (to) => {
const serverId = getRouteParam(to.params.id)
const worldId = getRouteParam(to.params.instance_id)
if (!serverId || !worldId) return
if (import.meta.client) startLoading()
try {
const auth = await useAuth()
if (!auth.value.token) return
const config = useRuntimeConfig()
const queryClient = useAppQueryClient()
const client = createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
rateLimitKey: config.rateLimitKey,
})
await queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
})
const tab = getInstanceTab(to.path)
if (tab === 'backups') {
await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId, worldId],
queryFn: () => client.archon.backups_v1.list(serverId, worldId),
staleTime: 30_000,
})
return
}
if (tab === 'files') {
const path = typeof to.query.path === 'string' ? to.query.path : '/'
await queryClient.ensureQueryData({
queryKey: ['files', serverId, path],
queryFn: () => client.kyros.files_v0.listDirectory(path, 1, 2000),
staleTime: 30_000,
})
return
}
const content = await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId, worldId],
queryFn: () => client.archon.content_v1.getAddons(serverId, worldId, { from_modpack: false }),
staleTime: 30_000,
})
const modpackProjectId =
content.modpack?.spec.platform === 'modrinth' ? content.modpack.spec.project_id : null
if (modpackProjectId) {
await queryClient.ensureQueryData({
queryKey: ['labrinth', 'project', modpackProjectId],
queryFn: () => client.labrinth.projects_v2.get(modpackProjectId),
staleTime: 30_000,
})
}
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
} finally {
if (import.meta.client) stopLoading()
}
})
function getRouteParam(param: string | string[] | undefined): string | null {
if (Array.isArray(param)) return param[0] ?? null
return param ?? null
}
function getInstanceTab(path: string): 'content' | 'files' | 'backups' {
const segments = path.split('/').filter(Boolean)
const lastSegment = segments[segments.length - 1]
if (lastSegment === 'files') return 'files'
return lastSegment === 'backups' ? 'backups' : 'content'
}
@@ -0,0 +1,40 @@
import { createModrinthClient } from '~/helpers/api.ts'
export default defineNuxtRouteMiddleware(async (to) => {
const match = to.path.match(/^\/hosting\/manage\/([^/]+)\/(content|files|backups)\/?$/)
if (!match) return
const serverId = decodeURIComponent(match[1])
const tab = match[2]
const instancesPath = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
const tabPath = tab === 'content' ? '' : `/${tab}`
const auth = await useAuth()
if (auth.value.token) {
try {
const config = useRuntimeConfig()
const client = createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
rateLimitKey: config.rateLimitKey,
})
const serverFull = await client.archon.servers_v1.get(serverId)
const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0]
if (world) {
return navigateTo(
{
path: `${instancesPath}/${encodeURIComponent(world.id)}${tabPath}`,
query: to.query,
hash: to.hash,
},
{ replace: true },
)
}
} catch {
return navigateTo({ path: instancesPath, query: to.query, hash: to.hash }, { replace: true })
}
}
return navigateTo({ path: instancesPath, query: to.query, hash: to.hash }, { replace: true })
})
+232 -423
View File
@@ -459,358 +459,8 @@
:project="project"
:project-v3="projectV3"
:member="!!currentMember"
>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand" circular>
<nuxt-link
v-tooltip="'Edit project'"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold lg:!hidden"
>
<SettingsIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold max-lg:!hidden"
>
<SettingsIcon aria-hidden="true" />
Edit project
</nuxt-link>
</ButtonStyled>
<div class="hidden sm:contents">
<ButtonStyled
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
:circular="!!auth.user && !!currentMember"
>
<button
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
{{
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
}}
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
:circular="!!auth.user && !!currentMember"
>
<button
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
@click="handlePlayServerProject"
>
<PlayIcon aria-hidden="true" />
{{ auth.user && currentMember ? '' : 'Play' }}
</button>
</ButtonStyled>
</div>
<div class="contents sm:hidden">
<ButtonStyled
v-if="!isServerProject"
size="large"
circular
:color="
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
>
<button
:aria-label="formatMessage(commonMessages.downloadButton)"
class="flex sm:hidden"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
circular
:color="
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
>
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
<PlayIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
theme="dismissable-prompt"
:triggers="[]"
:shown="flags.showProjectPageCreateServersTooltip"
:auto-hide="false"
placement="bottom-start"
>
<ButtonStyled size="large" circular>
<nuxt-link
v-tooltip="formatMessage(messages.createServerTooltip)"
:to="`/hosting?project=${project.id}#plan`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
}
"
>
<ServerPlusIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-60 items-center justify-between gap-4">
<h3
class="m-0 flex items-center gap-2 whitespace-nowrap text-base font-bold text-contrast"
>
{{ formatMessage(messages.serversPromoTitle) }}
<TagItem
:style="{
'--_color': 'var(--color-brand)',
'--_bg-color': 'var(--color-brand-highlight)',
}"
>{{ formatMessage(commonMessages.newBadge) }}</TagItem
>
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dontShowAgain)"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
}
"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.serversPromoDescription) }}
</p>
<p class="m-0 text-wrap text-sm font-bold text-primary">
<IntlFormatted
:message-id="messages.serversPromoPricing"
:values="{
price: formatPrice(500, 'USD', true),
}"
>
<template #small="{ children }">
<span class="text-xs">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</p>
</div>
</template>
</Tooltip>
<ButtonStyled size="large" circular>
<ClientOnly>
<button
v-if="auth.user"
v-tooltip="
following
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton)
"
:aria-label="
following
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton)
"
@click="userFollowProject(project)"
>
<HeartIcon :fill="following ? 'currentColor' : 'none'" aria-hidden="true" />
</button>
<nuxt-link
v-else
v-tooltip="formatMessage(commonMessages.followButton)"
:to="signInRouteObj"
:aria-label="formatMessage(commonMessages.followButton)"
>
<HeartIcon aria-hidden="true" />
</nuxt-link>
<template #fallback>
<nuxt-link
v-tooltip="formatMessage(commonMessages.followButton)"
:to="signInRouteObj"
:aria-label="formatMessage(commonMessages.followButton)"
>
<HeartIcon aria-hidden="true" />
</nuxt-link>
</template>
</ClientOnly>
</ButtonStyled>
<ButtonStyled size="large" circular>
<PopoutMenu
v-if="auth.user"
:tooltip="
collections.some((x) => x.projects.includes(project.id))
? formatMessage(commonMessages.savedLabel)
: formatMessage(commonMessages.saveButton)
"
from="top-right"
:aria-label="formatMessage(commonMessages.saveButton)"
:dropdown-id="`${baseId}-save`"
>
<BookmarkIcon
aria-hidden="true"
:fill="
collections.some((x) => x.projects.includes(project.id))
? 'currentColor'
: 'none'
"
/>
<template #menu>
<StyledInput
v-model="displayCollectionsSearch"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
wrapper-class="menu-search"
/>
<div v-if="collections.length > 0" class="collections-list text-primary">
<Checkbox
v-for="option in collections
.slice()
.sort((a, b) => a.name.localeCompare(b.name))"
:key="option.id"
:model-value="option.projects.includes(project.id)"
class="popout-checkbox"
@update:model-value="() => onUserCollectProject(option, project.id)"
>
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">{{ formatMessage(messages.noCollectionsFound) }}</p>
</div>
<ButtonStyled>
<button
class="mx-3 mb-3"
@click="(event) => $refs.modal_collection.show(event)"
>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createNewCollection) }}
</button>
</ButtonStyled>
</template>
</PopoutMenu>
<nuxt-link v-else v-tooltip="'Save'" :to="signInRouteObj" aria-label="Save">
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'analytics',
link: `/${project.project_type}/${project.slug ? project.slug : project.id}/settings/analytics`,
hoverOnly: true,
shown: auth.user && !!currentMember,
},
{
divider: true,
shown: auth.user && !!currentMember,
},
{
id: 'moderation-checklist',
action: openModerationChecklistFromMenu,
color: 'orange',
hoverOnly: true,
shown:
auth.user &&
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}`,
color: 'orange',
hoverOnly: true,
shown: auth.user && tags.staffRoles.includes(auth.user.role),
},
{
divider: true,
shown: auth.user && tags.staffRoles.includes(auth.user.role),
},
{
id: 'report',
action: () =>
auth.user
? reportProject(project.id)
: navigateTo(
getSignInRouteObj(route, getReportPath('project', project.id)),
),
color: 'red',
hoverOnly: true,
shown: !isMember,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
:dropdown-id="`${baseId}-more-options`"
>
<MoreVerticalIcon aria-hidden="true" />
<template #analytics>
<ChartIcon aria-hidden="true" />
{{ formatMessage(commonMessages.analyticsButton) }}
</template>
<template #moderation-checklist>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
</template>
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
<template #report>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
:actions="projectHeaderActions"
/>
<ProjectMemberHeader
v-if="currentMember"
:project="project"
@@ -1042,7 +692,6 @@
<script setup>
import {
BookmarkIcon,
BookTextIcon,
CalendarIcon,
ChartIcon,
@@ -1058,7 +707,6 @@ import {
ModrinthIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
ScanEyeIcon,
@@ -1067,7 +715,6 @@ import {
SettingsIcon,
VersionIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
@@ -1079,12 +726,9 @@ import {
getTagMessage,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
OpenInAppModal,
OverflowMenu,
PopoutMenu,
PROJECT_DEP_MARKER_QUERY,
ProjectBackgroundGradient,
ProjectEnvironmentModal,
@@ -1099,7 +743,6 @@ import {
ScrollablePanel,
ServersPromo,
StyledInput,
TagItem,
useDebugLogger,
useFormatDateTime,
useFormatPrice,
@@ -1111,7 +754,6 @@ import { capitalizeString, formatProjectType, renderString } from '@modrinth/uti
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
import { navigateTo } from '#app'
@@ -1122,6 +764,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 ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
@@ -1152,7 +795,7 @@ const auth = await useAuth()
const user = await useUser()
// Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = useRouteId('project')
const routeProjectId = ref(useRouteId('project'))
const { createProjectDownloadUrl } = useCdnDownloadContext()
@@ -1200,6 +843,7 @@ const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.valu
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const modalCollection = useTemplateRef('modal_collection')
const baseId = useId()
@@ -1630,17 +1274,12 @@ const filteredAlpha = computed(() => {
)
})
const displayCollectionsSearch = ref('')
const collections = computed(() =>
user.value && user.value.collections
? user.value.collections.filter((x) =>
x.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
)
: [],
user.value && user.value.collections ? user.value.collections : [],
)
if (
!routeProjectId ||
!routeProjectId.value ||
!(
tags.value.projectTypes.find((x) => x.id === route.params.type) ||
route.params.type === 'project'
@@ -1659,8 +1298,8 @@ const queryClient = useQueryClient()
// V2 Project - hits middleware cache (uses route param for lookup)
const { data: projectRaw, error: projectV2Error } = useQuery({
queryKey: computed(() => ['project', 'v2', routeProjectId]),
queryFn: () => client.labrinth.projects_v2.get(routeProjectId),
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value),
staleTime: STALE_TIME,
})
@@ -1709,8 +1348,8 @@ const {
error: _projectV3Error,
isPending: projectV3Pending,
} = useQuery({
queryKey: computed(() => ['project', 'v3', routeProjectId]),
queryFn: () => client.labrinth.projects_v3.get(routeProjectId),
queryKey: computed(() => ['project', 'v3', routeProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(routeProjectId.value),
staleTime: STALE_TIME,
})
@@ -1911,31 +1550,10 @@ function loadDependencies() {
// This allows showing/hiding UI elements without loading full version data
const hasVersions = computed(() => (project.value?.versions?.length ?? 0) > 0)
async function updateProjectRoute() {
if (
project.value &&
routeProjectId !== project.value.slug &&
!flags.value.disablePrettyProjectUrlRedirects
) {
await navigateTo(
{
name: route.name,
params: {
...route.params,
id: project.value.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
}
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId] })
if (routeProjectId !== projectId.value) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
if (routeProjectId.value !== projectId.value) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId.value] })
}
@@ -1947,22 +1565,37 @@ async function invalidateProject() {
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v2.edit(projectId, data)
if (data.slug !== undefined && data.slug !== route.params.project) {
routeProjectId.value = data.slug
await navigateTo(
{
name: route.name,
params: {
type: route.params.type,
project: data.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
return data
},
onMutate: async ({ projectId, data }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
// The query may be keyed by slug (routeProjectId) but we also have the actual UUID (projectId)
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId] })
if (routeProjectId !== projectId) {
// The query may be keyed by slug (routeProjectId.value) but we also have the actual UUID (projectId)
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
}
// Snapshot previous value from the active query (uses route param as key)
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId])
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
// Optimistic update on the active query key
queryClient.setQueryData(['project', 'v2', routeProjectId], (old) => {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
return { ...old, ...data }
})
@@ -1973,7 +1606,7 @@ const patchProjectMutation = useMutation({
onError: (err, _variables, context) => {
// Rollback on error using the active query key
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId], context.previousProject)
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -1995,16 +1628,16 @@ const patchStatusMutation = useMutation({
onMutate: async ({ projectId, status }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId] })
if (routeProjectId !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
}
// Snapshot previous value from the active query (uses route param as key)
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId])
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
// Optimistic update on the active query key
queryClient.setQueryData(['project', 'v2', routeProjectId], (old) => {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
return { ...old, status }
})
@@ -2015,7 +1648,7 @@ const patchStatusMutation = useMutation({
onError: (err, _variables, context) => {
// Rollback on error using the active query key
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId], context.previousProject)
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -2121,11 +1754,11 @@ const createGalleryItemMutation = useMutation({
},
onMutate: async ({ title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId] })
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId])
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
queryClient.setQueryData(['project', 'v2', routeProjectId], (old) => {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
const newItem = {
url: '',
@@ -2147,7 +1780,7 @@ const createGalleryItemMutation = useMutation({
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId], context.previousProject)
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -2172,11 +1805,11 @@ const editGalleryItemMutation = useMutation({
},
onMutate: async ({ imageUrl, title, description, featured, ordering }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId] })
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId])
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
queryClient.setQueryData(['project', 'v2', routeProjectId], (old) => {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
return {
...old,
@@ -2200,7 +1833,7 @@ const editGalleryItemMutation = useMutation({
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId], context.previousProject)
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -2220,11 +1853,11 @@ const deleteGalleryItemMutation = useMutation({
},
onMutate: async ({ imageUrl }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId] })
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId])
const previousProject = queryClient.getQueryData(['project', 'v2', routeProjectId.value])
queryClient.setQueryData(['project', 'v2', routeProjectId], (old) => {
queryClient.setQueryData(['project', 'v2', routeProjectId.value], (old) => {
if (!old) return old
return {
...old,
@@ -2237,7 +1870,7 @@ const deleteGalleryItemMutation = useMutation({
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v2', routeProjectId], context.previousProject)
queryClient.setQueryData(['project', 'v2', routeProjectId.value], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -2332,9 +1965,8 @@ watch(
const projectTypeDisplay = computed(() => {
if (!project.value) return ''
return formatProjectType(
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
)
const projectType = isServerProject.value ? 'minecraft_java_server' : project.value.project_type
return formatProjectType(data.$getProjectTypeForDisplay(projectType, project.value.loaders))
})
const following = computed(() => {
@@ -2360,6 +1992,184 @@ const canCreateServerFrom = computed(() => {
return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported'
})
const projectHeaderActions = computed(() => {
if (!project.value) return []
const projectPath = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const hasMember = !!currentMember.value
const userSignedIn = !!auth.value.user
const mutedPrimaryAction = hasMember || route.name === 'type-project-version-version'
const primaryLabel = isServerProject.value ? 'Play' : formatMessage(commonMessages.downloadButton)
return [
...(userSignedIn && hasMember
? [
{
id: 'edit-project',
label: 'Edit project',
icon: SettingsIcon,
color: 'brand',
to: `${projectPath}/settings`,
},
]
: []),
{
id: isServerProject.value ? 'play' : 'download',
label: primaryLabel,
icon: isServerProject.value ? PlayIcon : DownloadIcon,
color: mutedPrimaryAction ? 'standard' : 'brand',
labelHidden: userSignedIn && hasMember,
tooltip: userSignedIn && hasMember ? primaryLabel : undefined,
onClick: (event) => {
if (isServerProject.value) {
handlePlayServerProject()
} else {
downloadModal.value?.show(event)
}
},
},
...(canCreateServerFrom.value && flags.value.showProjectPageQuickServerButton
? [
{
id: 'create-server',
label: formatMessage(messages.serversPromoTitle),
icon: ServerPlusIcon,
labelHidden: true,
tooltip: formatMessage(messages.createServerTooltip),
to: `/hosting?project=${project.value.id}#plan`,
onClick: () => {
flags.value.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
},
prompt: {
title: formatMessage(messages.serversPromoTitle),
description: formatMessage(messages.serversPromoDescription),
badge: formatMessage(commonMessages.newBadge),
footer: formatMessage(messages.serversPromoPricing, {
price: formatPrice(500, 'USD', true),
small: (children) => (Array.isArray(children) ? children.join('') : children),
}),
dismissLabel: formatMessage(messages.dontShowAgain),
shown: flags.value.showProjectPageCreateServersTooltip,
placement: 'bottom-start',
onDismiss: () => {
flags.value.showProjectPageCreateServersTooltip = false
saveFeatureFlags()
},
},
},
]
: []),
{
id: 'follow',
label: following.value
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton),
icon: HeartIcon,
iconProps: {
fill: following.value ? 'currentColor' : 'none',
},
labelHidden: true,
tooltip: following.value
? formatMessage(commonMessages.unfollowButton)
: formatMessage(commonMessages.followButton),
to: userSignedIn ? undefined : signInRouteObj.value,
onClick: userSignedIn ? () => userFollowProject(project.value) : undefined,
},
{
id: 'save',
label: formatMessage(commonMessages.saveButton),
component: ProjectCollectionSaveButton,
componentProps: {
authUser: auth.value.user,
signInRoute: signInRouteObj.value,
projectId: project.value.id,
collections: collections.value,
saved: collections.value.some((x) => x.projects.includes(project.value.id)),
baseId,
noCollectionsLabel: formatMessage(messages.noCollectionsFound),
createNewCollectionLabel: formatMessage(messages.createNewCollection),
collectProject: onUserCollectProject,
createCollection: (event) => modalCollection.value?.show(event),
},
},
{
id: 'more',
label: formatMessage(commonMessages.moreOptionsButton),
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: formatMessage(commonMessages.moreOptionsButton),
menuActions: [
{
id: 'analytics',
label: formatMessage(commonMessages.analyticsButton),
icon: ChartIcon,
link: `${projectPath}/settings/analytics`,
shown: userSignedIn && hasMember,
},
{
divider: true,
shown: userSignedIn && hasMember,
},
{
id: 'moderation-checklist',
label: formatMessage(messages.reviewProject),
icon: ScaleIcon,
action: openModerationChecklistFromMenu,
color: 'orange',
shown:
userSignedIn &&
tags.value.staffRoles.includes(auth.value.user.role) &&
!showModerationChecklist.value,
},
{
divider: true,
shown:
userSignedIn &&
tags.value.staffRoles.includes(auth.value.user.role) &&
!showModerationChecklist.value,
},
{
id: 'tech-review',
label: 'Tech review',
icon: ScanEyeIcon,
link: `/moderation/technical-review/${project.value.id}`,
color: 'orange',
shown: userSignedIn && tags.value.staffRoles.includes(auth.value.user.role),
},
{
divider: true,
shown: userSignedIn && tags.value.staffRoles.includes(auth.value.user.role),
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () =>
auth.value.user
? reportProject(project.value.id)
: navigateTo(getSignInRouteObj(route, getReportPath('project', project.value.id))),
color: 'red',
shown: !isMember.value,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => copyId(),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => copyPermalink(),
},
],
},
]
})
const createCanonicalUrl = () =>
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
@@ -2460,7 +2270,6 @@ async function patchProject(resData, quiet = false) {
{ projectId: project.value.id, data: resData },
{
onSuccess: async () => {
await updateProjectRoute()
if (!quiet) {
addNotification({
title: formatMessage(messages.projectUpdated),
@@ -47,8 +47,10 @@ const navItems = computed(() => {
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(currentMember.value?.user)
const hasPermissionsPage = computed(() =>
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
const hasPermissionsPage = computed(
() =>
flags.value.modpackPermissionsPage &&
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
)
const items = [
+7 -10
View File
@@ -4,7 +4,6 @@ import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const route = useRoute()
const allowTabChanging = computed(() => !route.query.sid)
@@ -48,15 +47,13 @@ const selectableProjectTypes = [
]
</script>
<template>
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
<section class="normal-page__header mb-4 flex flex-col gap-4">
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
replace
class="hidden md:flex"
/>
</section>
<div class="mx-auto box-border flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-6">
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
replace
class="hidden md:flex"
/>
<NuxtPage />
</div>
</template>
+88 -103
View File
@@ -54,9 +54,6 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
const filtersMenuOpen = ref(false)
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader')
const route = useRoute()
@@ -175,6 +172,11 @@ const {
onboardingModalRef,
debug,
})
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'DiscoverInstallHeader',
)
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
const content = project.minecraft_java_server?.content
@@ -198,49 +200,66 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
return undefined
}
async function search(requestParams: string) {
type DiscoverProjectSearchHit = Labrinth.Search.v2.ResultSearchProject & {
version_id?: string | null
}
function mapV3ProjectHit(hit: Labrinth.Search.v3.ResultSearchProject): DiscoverProjectSearchHit {
return {
...hit,
project_type: hit.project_types[0] ?? projectTypeId.value,
title: hit.name,
description: hit.summary,
versions: hit.version_id ? [hit.version_id] : [],
latest_version: hit.version_id,
icon_url: hit.icon_url ?? '',
client_side: 'unknown',
server_side: 'unknown',
}
}
async function fetchSearch(requestParams: string) {
debug('search() called', {
requestParams: requestParams.substring(0, 100),
isServer: isServerType.value,
projectTypeId: projectTypeId.value,
})
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (isServerType.value) {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
}
const url = `${base}search${requestParams}`
debug('search() fetching:', url.substring(0, 120))
const raw = await $fetch<Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults>(
url,
{
headers: withLabrinthCanaryHeader(),
},
)
const raw = await client.request<Labrinth.Search.v3.SearchResults>('/search', {
api: 'labrinth',
version: 3,
method: 'GET',
params: Object.fromEntries(new URLSearchParams(requestParams.replace(/^\?/, ''))),
headers: withLabrinthCanaryHeader(),
})
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
if ('hits_per_page' in raw) {
// v3 response (servers)
if (isServerType.value) {
return {
projectHits: [],
serverHits: raw.hits as Labrinth.Search.v3.ResultSearchProject[],
serverHits: raw.hits,
total_hits: raw.total_hits,
per_page: raw.hits_per_page,
}
}
return {
projectHits: raw.hits as Labrinth.Search.v2.ResultSearchProject[],
projectHits: raw.hits.map(mapV3ProjectHit),
serverHits: [],
total_hits: raw.total_hits,
per_page: raw.limit,
per_page: raw.hits_per_page,
}
}
async function search(requestParams: string) {
return await queryClient.ensureQueryData({
queryKey: ['discover', 'search', 'v3', requestParams],
queryFn: () => fetchSearch(requestParams),
staleTime: 30_000,
})
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
@@ -412,7 +431,7 @@ watch(
)
debug('calling initial refreshSearch')
searchState.refreshSearch()
await searchState.refreshSearch()
const ogTitle = computed(() =>
searchState.query.value
@@ -480,20 +499,30 @@ provideBrowseManager({
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<div
v-if="installContext"
ref="stickyInstallHeaderRef"
class="normal-page__header browse-install-header-bleed sticky top-0 z-20 mb-4 flex flex-col gap-2 border-0 bg-surface-1 py-3"
class="sticky top-0 z-20 -mx-6 border-0 border-solid border-divider bg-surface-1 px-6 pt-4"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader />
<BrowseInstallHeader divider bottom-padding />
</div>
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
<aside class="normal-page__sidebar" :aria-label="formatMessage(commonMessages.filtersLabel)">
<AdPlaceholder v-if="!auth.user && !serverData" />
<BrowseSidebar />
</aside>
<section class="normal-page__content">
<div class="flex flex-col gap-3">
<div
class="grid min-w-0 gap-3"
:class="
cosmetics.rightSearchLayout
? 'lg:grid-cols-[minmax(0,1fr)_18.75rem]'
: 'lg:grid-cols-[18.75rem_minmax(0,1fr)]'
"
>
<section
class="flex min-w-0 flex-col gap-3"
:class="cosmetics.rightSearchLayout ? 'lg:order-1' : 'lg:order-2'"
>
<BrowsePageLayout>
<template #display-mode-icon>
<GridIcon v-if="resultsDisplayMode === 'grid'" />
@@ -501,78 +530,34 @@ provideBrowseManager({
<ListIcon v-else />
</template>
</BrowsePageLayout>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</div>
</section>
</section>
<aside
class="min-w-0"
:class="cosmetics.rightSearchLayout ? 'lg:order-2' : 'lg:order-1'"
:aria-label="formatMessage(commonMessages.filtersLabel)"
>
<BrowseSidebar>
<template #prepend>
<AdPlaceholder v-if="!auth.user && !serverData" />
</template>
</BrowseSidebar>
</aside>
</div>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</template>
<style lang="scss" scoped>
.browse-install-header-bleed {
grid-column: 1 / -1;
margin-inline: -1.5rem;
padding-inline: 0.75rem !important;
&::after {
content: '';
position: absolute;
right: 50%;
bottom: 0;
width: 100vw;
border-bottom: 1px solid var(--surface-5);
transform: translateX(50%);
}
}
.normal-page__content {
display: contents;
@media screen and (min-width: 1024px) {
display: block;
}
}
.normal-page__sidebar {
grid-row: 3;
@media screen and (min-width: 1024px) {
display: block;
}
}
.filters-card {
padding: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
padding: var(--spacing-card-lg);
}
}
.content-wrapper {
grid-row: 1;
}
.pagination-after {
grid-row: 6;
}
.no-results {
text-align: center;
display: flow-root;
}
.loading-logo {
margin: 2rem;
}
.search-background {
width: 100%;
height: 20rem;
@@ -3,14 +3,11 @@
:server-id="serverId"
:reload-page="() => reloadNuxtApp({ path: route.path })"
:resolve-viewer="resolveViewer"
:show-copy-id-action="flags.developerMode"
:show-advanced-debug-info="flags.advancedDebugInfo"
:stripe-publishable-key="config.public.stripePublishableKey as string"
:site-url="config.public.siteUrl as string"
:products="products"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:intercom-app-id="config.public.intercomAppId as string"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -77,12 +74,6 @@ const authUser = auth.value?.user
}
: undefined
async function fetchIntercomToken(): Promise<{ token: string }> {
return $fetch('/api/intercom/messenger-jwt', {
query: { server_id: serverId },
})
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
return {
userId: auth.value?.user?.id ?? null,
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { injectModrinthClient } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const route = useNativeRoute()
const serverId = route.params.id as string
const client = injectModrinthClient()
const queryClient = useQueryClient()
if (serverId) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
</script>
<template>
<NuxtPage :route="route" />
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { injectModrinthClient, ServersManageInstanceRootLayout } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const route = useNativeRoute()
const serverId = route.params.id as string
const client = injectModrinthClient()
const queryClient = useQueryClient()
definePageMeta({
middleware: 'server-instance-ready',
})
if (serverId) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
}
</script>
<template>
<ServersManageInstanceRootLayout>
<NuxtPage :route="route" />
</ServersManageInstanceRootLayout>
</template>
@@ -14,7 +14,7 @@ const flags = useFeatureFlags()
if (worldId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId],
queryKey: ['backups', 'list', serverId, worldId.value],
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
staleTime: 30_000,
})
@@ -10,11 +10,13 @@ const client = injectModrinthClient()
const { server, serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
const route = useNativeRoute()
const initialPath = typeof route.query.path === 'string' ? route.query.path : '/'
try {
await queryClient.ensureQueryData({
queryKey: ['files', serverId, '/'],
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
queryKey: ['files', serverId, initialPath],
queryFn: () => client.kyros.files_v0.listDirectory(initialPath, 1, 2000),
staleTime: 30_000,
})
} catch {
@@ -38,7 +38,7 @@ const contentWorldId = await getContentWorldId()
if (contentWorldId) {
try {
const content = await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId],
queryKey: ['content', 'list', 'v1', serverId, contentWorldId],
queryFn: () =>
client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }),
staleTime: 30_000,
@@ -0,0 +1,216 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
commonMessages,
defineMessages,
formatLoaderLabel,
injectModrinthClient,
injectModrinthServerContext,
ServersManageInstancesPage,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { server, serverId, isServerRunning } = injectModrinthServerContext()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'servers.manage.instances.meta.title',
defaultMessage: 'Instances - {server} - Modrinth',
},
instanceSlotName: {
id: 'servers.manage.instances.slot-name',
defaultMessage: 'Instance #{index}',
},
})
type LinkedModpack = {
name: string
iconUrl: string | null
link: string | null
}
type WorldSlot =
| {
type: 'world'
id: string
name: string
active: boolean
gameVersion: string | null
loaderLabel: string | null
linkedModpack: LinkedModpack | null
installedContentCount: number | null
lastActiveAt: string | null
createdAt: string | null
}
| {
type: 'empty'
id: string
name: string
}
type ContentSummary = {
gameVersion: string | null
loader: string | null
loaderVersion: string | null
linkedModpack: LinkedModpack | null
installedContentCount: number | null
}
const WORLD_SLOT_COUNT = 3
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'worlds', 'summary', 'v1', serverId],
queryFn: loadWorldSlots,
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
useHead({
title: () =>
formatMessage(messages.title, {
server: server.value?.name ?? formatMessage(commonMessages.serverLabel),
}),
})
async function loadWorldSlots(): Promise<WorldSlot[]> {
const serverFull = await client.archon.servers_v1.get(serverId)
const slots = await Promise.all(
serverFull.worlds.map(async (world, index) => {
const content = await loadContentSummary(world, index)
return toWorldSlot(world, content)
}),
)
return padWorldSlots(slots)
}
async function loadContentSummary(
world: Archon.Servers.v1.WorldFull,
index: number,
): Promise<ContentSummary> {
try {
const content = await client.archon.content_v1.getAddons(serverId, world.id, {
addons: true,
updates: false,
})
return {
gameVersion: content.game_version ?? world.content?.game_version ?? null,
loader: content.modloader ?? world.content?.modloader ?? null,
loaderVersion: content.modloader_version ?? world.content?.modloader_version ?? null,
linkedModpack: getLinkedModpack(content.modpack),
installedContentCount: content.addons?.length ?? 0,
}
} catch {
return createDummyContentSummary(world, index)
}
}
function toWorldSlot(world: Archon.Servers.v1.WorldFull, content: ContentSummary): WorldSlot {
return {
type: 'world',
id: world.id,
name: world.name,
active: world.is_active,
gameVersion: content.gameVersion,
loaderLabel: getLoaderLabel(content.loader, content.loaderVersion),
linkedModpack: content.linkedModpack,
installedContentCount: content.installedContentCount,
lastActiveAt: getLatestKnownActivity(world),
createdAt: world.created_at,
}
}
function padWorldSlots(slots: WorldSlot[]): WorldSlot[] {
const padded = [...slots]
for (let i = padded.length; i < WORLD_SLOT_COUNT; i++) {
padded.push({
type: 'empty',
id: `empty-world-slot-${i + 1}`,
name: formatMessage(messages.instanceSlotName, { index: i + 1 }),
})
}
return padded
}
function getLinkedModpack(modpack: Archon.Content.v1.ModpackFields | null): LinkedModpack | null {
if (!modpack) return null
const name =
modpack.title ??
(modpack.spec.platform === 'local_file' ? modpack.spec.name : modpack.spec.project_id)
return {
name,
iconUrl: modpack.icon_url ?? null,
link: modpack.spec.platform === 'modrinth' ? `/project/${modpack.spec.project_id}` : null,
}
}
function getLoaderLabel(loader: string | null, loaderVersion: string | null): string | null {
if (!loader) return null
const normalizedLoader = loader.toLowerCase()
return [formatLoaderLabel(normalizedLoader), loaderVersion].filter(Boolean).join(' ')
}
function getLatestKnownActivity(world: Archon.Servers.v1.WorldFull): string | null {
const latestBackup = latestDate(world.backups.map((backup) => backup.created_at))
if (latestBackup) return latestBackup
if (world.is_active && isServerRunning.value) return new Date().toISOString()
return world.created_at ?? null
}
function latestDate(dates: string[]): string | null {
let latest = 0
let latestIso: string | null = null
for (const date of dates) {
const timestamp = new Date(date).getTime()
if (!Number.isFinite(timestamp) || timestamp <= latest) continue
latest = timestamp
latestIso = date
}
return latestIso
}
function createDummyContentSummary(
world: Archon.Servers.v1.WorldFull,
index: number,
): ContentSummary {
const gameVersion = world.content?.game_version ?? server.value?.mc_version ?? '1.20.4'
const loader = world.content?.modloader ?? server.value?.loader?.toLowerCase() ?? 'fabric'
const loaderVersion = world.content?.modloader_version ?? server.value?.loader_version ?? '0.16.6'
if (index === 0) {
return {
gameVersion,
loader,
loaderVersion,
linkedModpack: {
name: 'Cobblemon Official',
iconUrl: null,
link: null,
},
installedContentCount: 47,
}
}
return {
gameVersion,
loader,
loaderVersion,
linkedModpack: null,
installedContentCount: 13,
}
}
</script>
<template>
<ServersManageInstancesPage />
</template>
+7 -2
View File
@@ -102,7 +102,12 @@
<tr>
<td>Revenue earned on</td>
<td>
<StyledInput id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<DatePicker
id="revenue-date-picker"
v-model="rawSelectedDate"
show-today
position="above"
/>
<noscript
>(JavaScript must be enabled for the date picker to function, example date:
2024-07-15)
@@ -162,7 +167,7 @@
</template>
<script lang="ts" setup>
import { injectModrinthClient, StyledInput, useFormatDateTime, useFormatMoney } from '@modrinth/ui'
import { DatePicker, injectModrinthClient, useFormatDateTime, useFormatMoney } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
+79 -22
View File
@@ -34,7 +34,7 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -42,7 +42,7 @@
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
@@ -113,6 +113,7 @@
v-else
:key="item.project.id"
:queue-entry="item"
:show-external-dependencies="currentFilterType === MODPACK_FILTER_TYPE"
@start-from-project="startFromProject"
/>
</div>
@@ -246,12 +247,25 @@ const filterTypes: ComboboxOption<string>[] = [
const filterTypeValues = filterTypes.map((option) => option.value)
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const sortTypes: ComboboxOption<string>[] = [
const MODPACK_FILTER_TYPE = 'Modpacks'
const baseSortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const sortTypeValues = sortTypes.map((option) => option.value)
const DEFAULT_SORT_TYPE = sortTypeValues[0]
const modpackSortTypes: ComboboxOption<string>[] = [
{ value: 'Most external deps', label: 'Most external deps' },
{ value: 'Least external deps', label: 'Least external deps' },
]
const DEFAULT_SORT_TYPE = baseSortTypes[0].value
const modpackSortTypeValues = modpackSortTypes.map((option) => option.value)
const sortTypes = computed(() => {
if (currentFilterType.value === MODPACK_FILTER_TYPE) {
return [...baseSortTypes, ...modpackSortTypes]
}
return baseSortTypes
})
const itemsPerPageOptions: ComboboxOption<number>[] = [
{ value: 20, label: '20' },
@@ -269,17 +283,31 @@ function parseFilterTypeFromQuery(value: LocationQueryValue | LocationQueryValue
return filterTypeValues.includes(query) ? query : DEFAULT_FILTER_TYPE
}
function parseSortTypeFromQuery(value: LocationQueryValue | LocationQueryValue[]): string {
function parseSortTypeFromQuery(
value: LocationQueryValue | LocationQueryValue[],
filterType: string,
): string {
const query = queryAsStringOrEmpty(value)
return sortTypeValues.includes(query) ? query : DEFAULT_SORT_TYPE
const validValues = [
...baseSortTypes.map((option) => option.value),
...(filterType === MODPACK_FILTER_TYPE ? modpackSortTypeValues : []),
]
return validValues.includes(query) ? query : DEFAULT_SORT_TYPE
}
const currentFilterType = ref(parseFilterTypeFromQuery(route.query.filter))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort))
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort, currentFilterType.value))
watch(
currentFilterType,
(newFilter) => {
if (
newFilter !== MODPACK_FILTER_TYPE &&
modpackSortTypeValues.includes(currentSortType.value)
) {
currentSortType.value = DEFAULT_SORT_TYPE
}
const currentQuery = { ...route.query }
if (newFilter && newFilter !== DEFAULT_FILTER_TYPE) {
currentQuery.filter = newFilter
@@ -326,7 +354,7 @@ watch(
watch(
() => route.query.sort,
(newSortParam) => {
const newValue = parseSortTypeFromQuery(newSortParam)
const newValue = parseSortTypeFromQuery(newSortParam, currentFilterType.value)
if (currentSortType.value !== newValue) {
currentSortType.value = newValue
}
@@ -423,7 +451,23 @@ const typeFiltered = computed(() => {
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Oldest') {
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()
@@ -471,7 +515,7 @@ function goToPage(page: number) {
currentPage.value = page
}
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
let skippedCount = 0
while (moderationQueue.hasItems) {
@@ -481,24 +525,30 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
if (project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
try {
const lockStatus = await moderationQueue.checkLock(currentId)
if (!lockStatus.locked || lockStatus.expired) {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
title: 'Skipped projects',
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
type: 'info',
})
}
return project
}
// Project is locked, skip it
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
@@ -506,6 +556,14 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
}
}
if (skippedCount > 0) {
addNotification({
title: 'Skipped projects',
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
type: 'info',
})
}
return null
}
@@ -517,12 +575,12 @@ async function moderateAllInFilter() {
await moderationQueue.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
const targetProject = await findFirstEligibleProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
type: 'warning',
})
return
@@ -553,13 +611,12 @@ async function startFromProject(projectId: string) {
await moderationQueue.setQueue(projectIds)
}
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
const targetProject = await findFirstEligibleProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
type: 'warning',
})
return
@@ -62,86 +62,14 @@
</template>
<template v-else>
<div class="normal-page__header py-4">
<ContentPageHeader>
<template #icon>
<Avatar :src="organization.icon_url" :alt="organization.name" size="96px" />
</template>
<template #title>
{{ organization.name }}
</template>
<template #title-suffix>
<div class="ml-1 flex items-center gap-2 font-semibold">
<OrganizationIcon />
Organization
</div>
</template>
<template #summary>
{{ organization.description }}
</template>
<template #stats>
<div
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<UsersIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(acceptedMembers?.length || 0) }}
members
</div>
<div
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(projects?.length || 0) }}
projects
</div>
<div
v-tooltip="formatNumber(sumDownloads)"
class="flex items-center gap-2 font-semibold"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(sumDownloads) }}
downloads
</div>
</template>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large">
<NuxtLink :to="`/organization/${organization.slug}/settings`">
<SettingsIcon aria-hidden="true" />
Manage
</NuxtLink>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:options="[
{
id: 'manage-projects',
action: () =>
router.push('/organization/' + organization?.slug + '/settings/projects'),
hoverFilledOnly: true,
shown: !!(auth.user && currentMember),
},
{ divider: true, shown: !!(auth?.user && currentMember) },
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #manage-projects>
<BoxIcon aria-hidden="true" />
Manage projects
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentPageHeader>
<PageHeader
:header="organization.name"
:summary="organization.description"
:leading="organizationHeaderLeading"
:badges="organizationHeaderBadges"
:metadata="organizationHeaderMetadata"
:actions="organizationHeaderActions"
/>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder v-if="!auth.user" />
@@ -303,10 +231,9 @@ import {
Avatar,
ButtonStyled,
commonMessages,
ContentPageHeader,
injectModrinthClient,
NavTabs,
OverflowMenu,
PageHeader,
PROJECT_DEP_MARKER_QUERY,
ProjectCard,
ProjectCardList,
@@ -535,6 +462,93 @@ provideOrganizationContext(organizationContext)
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
const organizationHeaderLeading = computed(() => ({
type: 'avatar' as const,
src: organization.value?.icon_url,
alt: organization.value?.name,
avatarSize: '96px',
}))
const organizationHeaderBadges = computed(() => [
{
id: 'organization',
label: 'Organization',
icon: OrganizationIcon,
class: 'px-0 text-primary',
},
])
const organizationHeaderMetadata = computed(() => [
{
id: 'members',
label: `${formatCompactNumber(acceptedMembers.value?.length || 0)} members`,
icon: UsersIcon,
},
{
id: 'projects',
label: `${formatCompactNumber(projects.value?.length || 0)} projects`,
icon: BoxIcon,
},
{
id: 'downloads',
label: `${formatCompactNumber(sumDownloads.value)} downloads`,
icon: DownloadIcon,
tooltip: formatNumber(sumDownloads.value),
},
])
const organizationHeaderActions = computed(() => [
...(auth.value.user && currentMember.value
? [
{
id: 'manage',
label: 'Manage',
icon: SettingsIcon,
to: `/organization/${organization.value?.slug}/settings`,
},
]
: []),
{
id: 'more',
label: 'More options',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent' as const,
tooltip: 'More options',
menuActions: [
{
id: 'manage-projects',
label: 'Manage projects',
icon: BoxIcon,
action: () => {
void router.push(`/organization/${organization.value?.slug}/settings/projects`)
},
shown: !!(auth.value.user && currentMember.value),
},
{
divider: true,
shown: !!(auth.value.user && currentMember.value),
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => {
void copyId()
},
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => {
void copyPermalink()
},
},
],
},
])
watch(
[routeHasSettings, acceptedMembers, currentMember],
() => {
+13 -13
View File
@@ -16,10 +16,10 @@
"
:width="'550px'"
>
<div class="flex flex-col gap-4">
<div class="flex w-full flex-col">
<div class="flex flex-col gap-6">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
<span class="font-semibold">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<StyledInput
id="pat-name"
@@ -29,20 +29,20 @@
/>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
<span class="font-semibold">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div
id="pat-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
class="scope-items grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div
v-for="category in scopeCategories"
:key="category.name"
class="flex flex-col gap-2"
class="flex flex-col gap-1.5"
>
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
<h4 class="m-0 border-b border-divider text-base font-medium text-primary">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
@@ -58,15 +58,14 @@
</div>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-expires">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
<span class="font-semibold">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<StyledInput id="pat-expires" v-model="expires" type="date" />
<p></p>
<DatePicker id="pat-expires" v-model="expires" show-today wrapper-class="w-full" />
</div>
<div class="ml-auto flex gap-2">
<div class="ml-auto mt-4 flex gap-2">
<ButtonStyled type="outlined">
<button @click="$refs.patModal.hide()">
<XIcon />
@@ -207,6 +206,7 @@ import {
commonSettingsMessages,
ConfirmModal,
CopyCode,
DatePicker,
defineMessages,
injectModrinthClient,
injectNotificationManager,
+162 -163
View File
@@ -120,164 +120,14 @@
</NewModal>
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
<div class="normal-page__header py-4">
<ContentPageHeader>
<template #icon>
<Avatar :src="user.avatar_url" :alt="user.username" size="96px" circle />
</template>
<template #title>
<span class="flex items-center gap-2">
{{ user.username }}
<TagItem
v-if="isAdminViewing && isAffiliate"
:style="{
'--_color': 'var(--color-brand)',
'--_bg-color': 'var(--color-brand-highlight)',
}"
>
<AffiliateIcon /> Affiliate
</TagItem>
</span>
</template>
<template #summary>
{{
user.bio
? user.bio
: projects.length === 0
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator)
}}
</template>
<template #stats>
<div
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-6 w-6 text-secondary" />
{{
formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects?.length || 0),
countPlural: formatCompactNumberPlural(projects?.length || 0),
})
}}
</div>
<div
v-tooltip="formatNumber(sumDownloads)"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{
formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads),
countPlural: formatCompactNumberPlural(sumDownloads),
})
}}
</div>
<div
v-tooltip="formatDateTime(user.created)"
class="flex items-center gap-2 font-semibold"
>
<CalendarIcon class="h-6 w-6 text-secondary" />
{{ formatMessage(messages.profileJoinedLabel) }}
{{ formatRelativeTime(user.created) }}
</div>
</template>
<template #actions>
<ButtonStyled size="large">
<NuxtLink v-if="auth.user && auth.user.id === user.id" to="/settings/profile">
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:options="[
{
id: 'manage-projects',
action: () => navigateTo('/dashboard/projects'),
hoverOnly: true,
shown: auth.user && auth.user.id === user.id,
},
{ divider: true, shown: auth.user && auth.user.id === user.id },
{
id: 'report',
action: () =>
auth.user ? reportUser(user.id) : navigateTo(getSignInRouteObj(route)),
color: 'red',
hoverOnly: true,
shown: auth.user?.id !== user.id,
},
{ id: 'copy-id', action: () => copyId() },
{ id: 'copy-permalink', action: () => copyPermalink() },
{
divider: true,
shown: auth.user && isAdmin(auth.user),
},
{
id: 'open-billing',
action: () => navigateTo(`/admin/billing/${user.id}`),
shown: auth.user && isStaff(auth.user),
},
{
id: 'toggle-affiliate',
action: () => toggleAffiliate(user.id),
shown: isAdminViewing,
remainOnClick: true,
color: isAffiliate ? 'red' : 'orange',
},
{
id: 'open-info',
action: () => $refs.userDetailsModal.show(),
shown: auth.user && isStaff(auth.user),
},
{
id: 'edit-role',
action: () => openRoleEditModal(),
shown: auth.user && isAdmin(auth.user),
},
]"
aria-label="More options"
:dropdown-id="`${baseId}-more-options`"
>
<MoreVerticalIcon aria-hidden="true" />
<template #manage-projects>
<BoxIcon aria-hidden="true" />
{{ formatMessage(messages.profileManageProjectsButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyPermalinkButton) }}
</template>
<template #open-billing>
<CurrencyIcon aria-hidden="true" />
{{ formatMessage(messages.billingButton) }}
</template>
<template #open-info>
<InfoIcon aria-hidden="true" />
{{ formatMessage(messages.infoButton) }}
</template>
<template #toggle-affiliate>
<AffiliateIcon aria-hidden="true" />
{{
formatMessage(
isAffiliate ? messages.removeAffiliateButton : messages.setAffiliateButton,
)
}}
</template>
<template #edit-role>
<EditIcon aria-hidden="true" />
{{ formatMessage(messages.editRoleButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentPageHeader>
<PageHeader
:header="user.username"
:summary="profileHeaderSummary"
:leading="profileHeaderLeading"
:badges="profileHeaderBadges"
:metadata="profileHeaderMetadata"
:actions="profileHeaderActions"
/>
</div>
<div class="normal-page__content">
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
@@ -489,17 +339,15 @@ import {
ButtonStyled,
Combobox,
commonMessages,
ContentPageHeader,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
OverflowMenu,
PageHeader,
ProjectCard,
ProjectCardList,
TagItem,
useCompactNumber,
useFormatDateTime,
useFormatNumber,
@@ -543,8 +391,6 @@ const formatDateTime = useFormatDateTime({
const { addNotification } = injectNotificationManager()
const baseId = useId()
const messages = defineMessages({
profileProjectsLabel: {
id: 'profile.label.projects',
@@ -864,12 +710,165 @@ async function copyPermalink() {
const isAffiliate = computed(() => user.value?.badges & UserBadge.AFFILIATE)
const isAdminViewing = computed(() => isAdmin(auth.value.user))
const userDetailsModal = useTemplateRef('userDetailsModal')
async function toggleAffiliate(id) {
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
queryClient.invalidateQueries({ queryKey: ['user', userId] })
}
const profileHeaderSummary = computed(() =>
user.value?.bio
? user.value.bio
: (projects.value?.length ?? 0) === 0
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator),
)
const profileHeaderLeading = computed(() => ({
type: 'avatar',
src: user.value?.avatar_url,
alt: user.value?.username,
avatarSize: '96px',
circle: true,
}))
const profileHeaderBadges = computed(() =>
isAdminViewing.value && isAffiliate.value
? [
{
id: 'affiliate',
label: 'Affiliate',
icon: AffiliateIcon,
class: 'border-brand-highlight bg-brand-highlight text-brand',
},
]
: [],
)
const profileHeaderMetadata = computed(() => [
{
id: 'projects',
label: formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects.value?.length || 0),
countPlural: formatCompactNumberPlural(projects.value?.length || 0),
}),
icon: BoxIcon,
},
{
id: 'downloads',
label: formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads.value),
countPlural: formatCompactNumberPlural(sumDownloads.value),
}),
icon: DownloadIcon,
tooltip: formatNumber(sumDownloads.value),
},
{
id: 'joined',
label: `${formatMessage(messages.profileJoinedLabel)} ${formatRelativeTime(user.value.created)}`,
icon: CalendarIcon,
tooltip: formatDateTime(user.value.created),
},
])
const profileHeaderActions = computed(() => {
if (!user.value) return []
const viewer = auth.value.user
const isSelf = viewer?.id === user.value.id
return [
...(isSelf
? [
{
id: 'edit-profile',
label: formatMessage(commonMessages.editButton),
icon: EditIcon,
to: '/settings/profile',
},
]
: []),
{
id: 'more',
label: 'More options',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: 'More options',
menuActions: [
{
id: 'manage-projects',
label: formatMessage(messages.profileManageProjectsButton),
icon: BoxIcon,
action: () => navigateTo('/dashboard/projects'),
shown: isSelf,
},
{
divider: true,
shown: isSelf,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () => (viewer ? reportUser(user.value.id) : navigateTo(getSignInRouteObj(route))),
color: 'red',
shown: viewer?.id !== user.value.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => copyId(),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => copyPermalink(),
},
{
divider: true,
shown: viewer && isAdmin(viewer),
},
{
id: 'open-billing',
label: formatMessage(messages.billingButton),
icon: CurrencyIcon,
action: () => navigateTo(`/admin/billing/${user.value.id}`),
shown: viewer && isStaff(viewer),
},
{
id: 'toggle-affiliate',
label: formatMessage(
isAffiliate.value ? messages.removeAffiliateButton : messages.setAffiliateButton,
),
icon: AffiliateIcon,
action: () => toggleAffiliate(user.value.id),
shown: isAdminViewing.value,
remainOnClick: true,
color: isAffiliate.value ? 'red' : 'orange',
},
{
id: 'open-info',
label: formatMessage(messages.infoButton),
icon: InfoIcon,
action: () => userDetailsModal.value?.show(),
shown: viewer && isStaff(viewer),
},
{
id: 'edit-role',
label: formatMessage(messages.editRoleButton),
icon: EditIcon,
action: () => openRoleEditModal(),
shown: viewer && isAdmin(viewer),
},
],
},
]
})
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),
@@ -0,0 +1,31 @@
{
"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,36 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n t.mod_id AS \"project_id!\",\n tm.created,\n tm.body AS \"body: sqlx::types::Json<MessageBody>\"\n FROM threads_messages tm\n INNER JOIN threads t ON t.id = tm.thread_id\n WHERE\n t.mod_id = ANY($1)\n AND tm.body->>'type' = 'status_change'\n AND tm.created BETWEEN $2 AND $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "body: sqlx::types::Json<MessageBody>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8Array",
"Timestamptz",
"Timestamptz"
]
},
"nullable": [
true,
false,
false
]
},
"hash": "a5141c0435441f062231c842cb5db5e0c78f8f3896c1e9f2b2b56cce41fa1591"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued\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 ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "ec1f08768071d55613b0b69b3eac43e1e5d0a532171b5de7b9086ae7376f1482"
}
@@ -53,6 +53,9 @@ pub struct ProjectsRequestOptions {
/// How many projects to skip.
#[serde(default)]
pub offset: u32,
/// Whether to filter by modpacks that have external dependencies.
#[serde(default)]
pub has_external_dependencies: Option<bool>,
}
fn default_count() -> u16 {
@@ -68,6 +71,8 @@ pub struct FetchedProject {
pub project: Project,
/// Who owns the project.
pub ownership: Ownership,
/// How many external file dependencies the project has.
pub external_dependencies_count: i64,
}
/// Fetched information on who owns a project.
@@ -190,13 +195,22 @@ pub async fn get_projects_internal(
use futures::stream::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT id
let project_rows = sqlx::query!(
r#"
SELECT
id,
external_dependencies_count as "external_dependencies_count!"
FROM (
SELECT DISTINCT ON (m.id)
m.id,
m.queued
m.queued,
(
SELECT COUNT(*)
FROM versions v
INNER JOIN dependencies d ON d.dependent_id = v.id
WHERE v.mod_id = m.id
AND d.dependency_file_name IS NOT NULL
) external_dependencies_count
FROM mods m
/* -- Temporarily, don't exclude projects in tech rev q
@@ -212,20 +226,36 @@ pub async fn get_projects_internal(
GROUP BY m.id
) t
WHERE
($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)
ORDER BY queued ASC
OFFSET $3
LIMIT $2
",
"#,
ProjectStatus::Processing.as_str(),
request_opts.count as i64,
request_opts.offset as i64
request_opts.offset as i64,
request_opts.has_external_dependencies,
)
.fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id))
.try_collect::<Vec<database::models::DBProjectId>>()
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("failed to fetch projects awaiting review")?;
let project_ids = project_rows
.iter()
.map(|m| database::models::DBProjectId(m.id))
.collect::<Vec<_>>();
let project_metadata = project_rows
.into_iter()
.map(|m| {
(
database::models::DBProjectId(m.id),
m.external_dependencies_count,
)
})
.collect::<HashMap<_, _>>();
let projects =
database::DBProject::get_many_ids(&project_ids, &**pool, &redis)
.await
@@ -240,7 +270,16 @@ pub async fn get_projects_internal(
let map_project =
|(project, ownership): (Project, Ownership)| -> FetchedProject {
FetchedProject { ownership, project }
let external_dependencies_count = project_metadata
.get(&database::models::DBProjectId(project.id.0 as i64))
.copied()
.unwrap_or_default();
FetchedProject {
ownership,
project,
external_dependencies_count,
}
};
let projects = projects
@@ -61,6 +61,7 @@ pub async fn get_projects(
web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
has_external_dependencies: None,
}),
session_queue,
)
+238 -43
View File
@@ -9,7 +9,7 @@
mod old;
use std::{num::NonZeroU64, sync::LazyLock};
use std::{collections::HashMap, num::NonZeroU64, sync::LazyLock};
use crate::database::PgPool;
use actix_web::{HttpRequest, post, web};
@@ -21,19 +21,24 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use crate::{
auth::{AuthenticationError, get_user_from_headers},
auth::{
AuthenticationError, checks::filter_visible_version_ids,
get_user_from_headers,
},
database::{
self, DBProject,
models::{
DBAffiliateCode, DBAffiliateCodeId, DBProjectId, DBUser, DBUserId,
DBVersionId,
DBVersion, DBVersionId,
},
redis::RedisPool,
},
models::{
ids::{AffiliateCodeId, ProjectId, VersionId},
pats::Scopes,
projects::ProjectStatus,
teams::ProjectPermissions,
threads::MessageBody,
v3::analytics::DownloadReason,
},
queue::session::AuthQueue,
@@ -255,17 +260,46 @@ pub const MAX_TIME_SLICES: usize = 1024;
/// Response for a [`GetRequest`].
#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FetchResponse {
pub struct GetResponse {
/// List of N [`TimeSlice`]s, where each slice represents an equal
/// time interval of metrics collection. The number of slices is determined
/// by [`GetRequest::time_range`].
pub metrics: Vec<TimeSlice>,
/// List of events associated with projects that were requested.
pub project_events: Vec<ProjectAnalyticsEvent>,
}
/// Single time interval of metrics collection.
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct TimeSlice(pub Vec<AnalyticsData>);
/// Notable update to a project which may reflect in analytics metrics.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectAnalyticsEvent {
/// ID of the event's project.
pub project_id: ProjectId,
/// When the event occurred.
pub timestamp: DateTime<Utc>,
#[serde(flatten)]
pub kind: ProjectAnalyticsEventKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProjectAnalyticsEventKind {
/// New version of this project was uploaded.
VersionUploaded {
version_id: VersionId,
version_name: String,
version_number: String,
},
/// Project changed status.
StatusChanged {
status_from: ProjectStatus,
status_to: ProjectStatus,
},
}
/// Metrics collected in a single [`TimeSlice`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(untagged)] // the presence of `source_project`, `source_affiliate_code` determines the kind
@@ -351,7 +385,7 @@ pub struct ProjectDownloads {
downloads: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, utoipa::ToSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, utoipa::ToSchema)]
pub enum DownloadSource {
Website,
ModrinthApp,
@@ -665,7 +699,7 @@ mod query {
/// Fetches analytics data for the authorized user's projects.
#[utoipa::path(
responses((status = OK, body = inline(FetchResponse))),
responses((status = OK, body = inline(GetResponse))),
)]
#[post("")]
pub async fn fetch_analytics(
@@ -675,7 +709,7 @@ pub async fn fetch_analytics(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
clickhouse: web::Data<clickhouse::Client>,
) -> Result<web::Json<FetchResponse>, ApiError> {
) -> Result<web::Json<GetResponse>, ApiError> {
let (scopes, user) = get_user_from_headers(
&http_req,
&**pool,
@@ -768,6 +802,44 @@ pub async fn fetch_analytics(
.iter()
.map(|version| DBProjectId(version.mod_id))
.collect::<Vec<_>>();
let parent_version_data =
DBVersion::get_many(&parent_version_ids, &**pool, &redis).await?;
let visible_version_ids = filter_visible_version_ids(
parent_version_data
.iter()
.map(|version| &version.inner)
.collect(),
&Some(user.clone()),
&pool,
&redis,
)
.await?;
let mut project_events = parent_version_data
.iter()
.filter(|version| {
visible_version_ids.contains(&version.inner.id)
&& version.inner.date_published >= req.time_range.start
&& version.inner.date_published <= req.time_range.end
})
.map(|version| ProjectAnalyticsEvent {
project_id: version.inner.project_id.into(),
timestamp: version.inner.date_published,
kind: ProjectAnalyticsEventKind::VersionUploaded {
version_id: version.inner.id.into(),
version_name: version.inner.name.clone(),
version_number: version.inner.version_number.clone(),
},
})
.collect::<Vec<_>>();
project_events.extend(
fetch_project_status_change_events(
&project_ids,
&req.time_range,
&pool,
)
.await?,
);
project_events.sort_by_key(|event| event.timestamp);
let affiliate_code_ids =
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
@@ -830,9 +902,8 @@ pub async fn fetch_analytics(
use ProjectDownloadsField as F;
let uses = |field| metrics.bucket_by.contains(&field);
query_clickhouse::<query::DownloadRow>(
query_clickhouse_downloads(
&mut query_clickhouse_cx,
query::DOWNLOADS,
&[
("use_project_id", uses(F::ProjectId)),
("use_domain", uses(F::Domain)),
@@ -844,37 +915,6 @@ pub async fn fetch_analytics(
("use_game_version", uses(F::GameVersion)),
("use_loader", uses(F::Loader)),
],
|row| row.bucket,
|row| {
let country = if uses(F::Country) {
Some(condense_country(row.country, row.downloads))
} else {
None
};
AnalyticsData::Project(ProjectAnalytics {
source_project: row.project_id.into(),
metrics: ProjectMetrics::Downloads(ProjectDownloads {
domain: none_if_empty(row.domain),
user_agent: if uses(F::UserAgent) {
normalize_download_source(&row.user_agent)
} else {
None
},
version_id: none_if_zero_version_id(row.version_id),
monetized: match row.monetized {
0 => Some(false),
1 => Some(true),
_ => None,
},
country,
reason: none_if_empty(row.reason)
.and_then(|s| s.parse().ok()),
game_version: none_if_empty(row.game_version),
loader: none_if_empty(row.loader),
downloads: row.downloads,
}),
})
},
)
.await?;
}
@@ -1103,8 +1143,9 @@ pub async fn fetch_analytics(
}
}
Ok(web::Json(FetchResponse {
Ok(web::Json(GetResponse {
metrics: time_slices,
project_events,
}))
}
@@ -1195,6 +1236,57 @@ fn condense_country(country: String, count: u64) -> String {
}
}
async fn fetch_project_status_change_events(
project_ids: &[DBProjectId],
time_range: &TimeRange,
pool: &PgPool,
) -> Result<Vec<ProjectAnalyticsEvent>, ApiError> {
let project_id_values =
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
let rows = sqlx::query!(
r#"
SELECT
t.mod_id AS "project_id!",
tm.created,
tm.body AS "body: sqlx::types::Json<MessageBody>"
FROM threads_messages tm
INNER JOIN threads t ON t.id = tm.thread_id
WHERE
t.mod_id = ANY($1)
AND tm.body->>'type' = 'status_change'
AND tm.created BETWEEN $2 AND $3
"#,
&project_id_values,
time_range.start,
time_range.end,
)
.fetch_all(&**pool)
.await?;
Ok(rows
.into_iter()
.filter_map(|row| {
let MessageBody::StatusChange {
old_status,
new_status,
} = row.body.0
else {
return None;
};
Some(ProjectAnalyticsEvent {
project_id: DBProjectId(row.project_id).into(),
timestamp: row.created,
kind: ProjectAnalyticsEventKind::StatusChanged {
status_from: old_status,
status_to: new_status,
},
})
})
.collect())
}
struct QueryClickhouseContext<'a> {
clickhouse: &'a clickhouse::Client,
req: &'a GetRequest,
@@ -1205,6 +1297,107 @@ struct QueryClickhouseContext<'a> {
affiliate_code_ids: &'a [DBAffiliateCodeId],
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct DownloadBucket {
bucket: u64,
project_id: DBProjectId,
domain: Option<String>,
user_agent: Option<DownloadSource>,
version_id: Option<DBVersionId>,
monetized: Option<bool>,
country: Option<String>,
reason: Option<DownloadReason>,
game_version: Option<String>,
loader: Option<String>,
}
async fn query_clickhouse_downloads(
cx: &mut QueryClickhouseContext<'_>,
use_columns: &[(&str, bool)],
) -> Result<(), ApiError> {
let mut query = cx
.clickhouse
.query(query::DOWNLOADS)
.param("time_range_start", cx.req.time_range.start.timestamp())
.param("time_range_end", cx.req.time_range.end.timestamp())
.param("time_slices", cx.time_slices.len())
.param("project_ids", cx.project_ids)
.param("parent_version_ids", cx.parent_version_ids)
.param("parent_version_project_ids", cx.parent_version_project_ids)
.param("affiliate_code_ids", cx.affiliate_code_ids);
for (param_name, used) in use_columns {
query = query.param(param_name, used)
}
let uses = |name| {
use_columns
.iter()
.any(|(column_name, used)| *column_name == name && *used)
};
let mut cursor = query.fetch::<query::DownloadRow>()?;
let mut buckets = HashMap::<DownloadBucket, u64>::new();
while let Some(row) = cursor.next().await? {
let key = DownloadBucket {
bucket: row.bucket,
project_id: row.project_id,
domain: uses("use_domain").then(|| row.domain.clone()),
user_agent: uses("use_user_agent")
.then(|| normalize_download_source(&row.user_agent))
.flatten(),
version_id: uses("use_version_id").then_some(row.version_id),
monetized: if uses("use_monetized") {
match row.monetized {
0 => Some(false),
1 => Some(true),
_ => None,
}
} else {
None
},
country: uses("use_country").then(|| row.country.clone()),
reason: if uses("use_reason") {
none_if_empty(row.reason.clone()).and_then(|s| s.parse().ok())
} else {
None
},
game_version: uses("use_game_version")
.then(|| row.game_version.clone()),
loader: uses("use_loader").then(|| row.loader.clone()),
};
*buckets.entry(key).or_default() += row.downloads;
}
for (key, downloads) in buckets {
let bucket = key.bucket as usize;
add_to_time_slice(
cx.time_slices,
bucket,
AnalyticsData::Project(ProjectAnalytics {
source_project: key.project_id.into(),
metrics: ProjectMetrics::Downloads(ProjectDownloads {
domain: key.domain.and_then(none_if_empty),
user_agent: key.user_agent,
version_id: key
.version_id
.and_then(none_if_zero_version_id),
monetized: key.monetized,
country: key
.country
.map(|country| condense_country(country, downloads)),
reason: key.reason,
game_version: key.game_version.and_then(none_if_empty),
loader: key.loader.and_then(none_if_empty),
downloads,
}),
}),
)?;
}
Ok(())
}
async fn query_clickhouse<Row>(
cx: &mut QueryClickhouseContext<'_>,
query: &str,
@@ -1395,7 +1588,7 @@ mod tests {
let test_project_2 = ProjectId(456);
let test_project_3 = ProjectId(789);
let src = FetchResponse {
let src = GetResponse {
metrics: vec![
TimeSlice(vec![
AnalyticsData::Project(ProjectAnalytics {
@@ -1422,6 +1615,7 @@ mod tests {
}),
})]),
],
project_events: vec![],
};
let target = json!({
"metrics": [
@@ -1446,7 +1640,8 @@ mod tests {
"revenue": "200.00",
}
]
]
],
"project_events": []
});
assert_eq!(serde_json::to_value(src).unwrap(), target);
@@ -103,7 +103,7 @@ export class ArchonServersV0Module extends AbstractModule {
*/
public async power(
serverId: string,
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
action: Archon.Servers.v0.PowerAction,
): Promise<void> {
await this.client.request(`/servers/${serverId}/power`, {
api: 'archon',
@@ -336,6 +336,8 @@ export namespace Archon {
token: string // JWT token for filesystem access
}
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
export type ReinstallLoaderRequest = {
loader: string
loader_version?: string
+2
View File
@@ -11,6 +11,7 @@ import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
@@ -71,6 +72,7 @@ export const MODULE_REGISTRY = {
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
@@ -14,6 +14,7 @@ export class KyrosContentV1Module extends AbstractModule {
* @param files - Files to upload as addons
* @param options - Optional progress callback
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
*/
public uploadAddonFile(
worldId: string,
@@ -94,6 +94,7 @@ export class KyrosFilesV0Module extends AbstractModule {
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
*/
public uploadFile(
path: string,
@@ -1,4 +1,32 @@
export namespace Kyros {
export namespace UploadSessions {
export namespace v1 {
export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active'
| 'uploading'
| 'finalizing'
| 'cancelled'
| 'finalized'
| 'expired'
export interface UploadSessionResponse {
upload_id: string
status: UploadSessionStatus
created_at: number
updated_at: number
last_upload_at: number | null
expires_at: number
entry_count: number
uploaded_byte_count: number
}
export interface GetUploadSessionResponse {
session: UploadSessionResponse | null
}
}
}
export namespace Files {
export namespace v0 {
export interface DirectoryItem {
@@ -0,0 +1,104 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
}
public async create(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async get(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'GET',
useNodeAuth: true,
},
)
}
public uploadFiles(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
files: UploadSessionFile[],
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
const formData = new FormData()
for (const { file, filename } of files) {
formData.append('file', file, filename)
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
{
api: '',
version: 'v1',
formData,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
},
)
}
public async finalize(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async cancel(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
{
api: '',
version: 'v1',
method: 'DELETE',
useNodeAuth: true,
},
)
}
}
+18 -12
View File
@@ -875,6 +875,7 @@ impl CachedEntry {
.fetch_all(pool)
.await?;
let now = Utc::now().timestamp();
for row in query {
let parsed_data = if let Some(data) = row.data.clone() {
Some(Self::deserialize_cache_value(type_, data, &row.id)?)
@@ -882,7 +883,7 @@ impl CachedEntry {
None
};
if row.expires <= Utc::now().timestamp() {
if row.expires <= now {
if cache_behaviour == CacheBehaviour::MustRevalidate {
continue;
} else {
@@ -890,6 +891,19 @@ impl CachedEntry {
}
}
let row_id = row.id.clone();
let row_alias = row.alias.clone();
let remove_matching_key = |x: &&str| {
x != &&*row_id
&& !row_alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true) {
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
};
if let Some(data) = parsed_data {
if data.get_type() != type_ {
return Err(crate::ErrorKind::OtherError(format!(
@@ -901,17 +915,7 @@ impl CachedEntry {
.as_error());
}
remaining_keys.retain(|x| {
x != &&*row.id
&& !row.alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true)
{
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
});
remaining_keys.retain(remove_matching_key);
return_vals.push(Self {
id: row.id,
@@ -920,6 +924,8 @@ impl CachedEntry {
data: Some(data),
expires: row.expires,
});
} else {
remaining_keys.retain(remove_matching_key);
}
}
}
+38 -61
View File
@@ -3,13 +3,12 @@
//! ## Data Flow
//!
//! 1. Frontend calls `get_content_items(profile_path)`
//! 2. Backend fetches all installed files via `Profile::get_projects()`
//! 3. If profile is linked to a modpack:
//! 2. If profile is linked to a modpack:
//! - Fetch modpack file hashes from cache (populated during installation)
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
//! - Filter out files that belong to the modpack
//! 4. For remaining files, fetch project/version/owner metadata in parallel
//! 5. Return sorted `ContentItem` list
//! - Filter out files that belong to the modpack before update lookup
//! 3. For remaining files, fetch project/version/owner metadata in parallel
//! 4. Return sorted `ContentItem` list
//!
//! ## Caching
//!
@@ -33,12 +32,12 @@ use std::io::Cursor;
/// Content item with rich metadata for frontend display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItem {
/// Unique identifier (the file name)
/// Display file name.
pub file_name: String,
/// Relative path to the file within the profile
pub file_path: String,
/// Stable frontend identifier (SHA1 hash of file content, survives renames).
/// Not a project or version ID.
/// SHA1 hash of file content. Stable across renames, but not unique when
/// duplicate files have identical contents.
pub id: String,
/// File size in bytes
pub size: u64,
@@ -226,12 +225,8 @@ pub async fn get_linked_modpack_info(
};
// Check for updates
let (has_update, update_version_id, update_version) = check_modpack_update(
profile,
&linked_data.version_id,
&version,
all_versions,
);
let (has_update, update_version_id, update_version) =
check_modpack_update(&linked_data.version_id, &version, all_versions);
Ok(Some(LinkedModpackInfo {
project,
@@ -243,10 +238,9 @@ pub async fn get_linked_modpack_info(
}))
}
/// Check if a newer compatible version exists for the linked modpack.
/// Check if a newer version exists for the linked modpack.
/// Returns (has_update, update_version_id, update_version).
fn check_modpack_update(
profile: &Profile,
installed_version_id: &str,
installed_version: &Version,
all_versions: Option<Vec<Version>>,
@@ -255,44 +249,19 @@ fn check_modpack_update(
return (false, None, None);
};
// Get the loader as a string for comparison
let loader_str = profile.loader.as_str().to_lowercase();
let game_version = &profile.game_version;
// Filter to compatible versions
let mut compatible_versions: Vec<&Version> = versions
let mut newer_versions: Vec<&Version> = versions
.iter()
.filter(|v| {
// Must support the profile's game version
let supports_game = v.game_versions.contains(game_version);
// Must support the profile's loader
// The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
// but if mrpack_loaders is missing, loaders may be just ["mrpack"].
// In that case we can't filter by loader, so accept the version.
let real_loaders: Vec<_> = v
.loaders
.iter()
.filter(|l| l.to_lowercase() != "mrpack")
.collect();
let supports_loader = real_loaders.is_empty()
|| real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
supports_game && supports_loader
v.id != installed_version_id
&& v.date_published > installed_version.date_published
})
.collect();
// Sort by date_published descending (newest first)
compatible_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
// Find the newest compatible version
if let Some(newest) = compatible_versions.first() {
// Check if the newest version is different and newer than installed
if newest.id != installed_version_id
&& newest.date_published > installed_version.date_published
{
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
if let Some(newest) = newer_versions.first() {
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
(false, None, None)
@@ -306,10 +275,6 @@ pub async fn get_content_items(
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
if linked_data.version_id.is_empty() {
None
@@ -350,23 +315,35 @@ pub async fn get_content_items(
None
};
let user_files: Vec<(String, ProfileFile)> = all_files
.into_iter()
.filter(|(_, file)| {
modpack_ids
.as_ref()
.is_none_or(|ids| !ids.is_modpack_file(file))
})
.collect();
let user_files: Vec<(String, ProfileFile)> = if let Some(ids) = &modpack_ids
{
let filtered_files = profile
.get_projects_excluding_modpack_files(
&ids.hashes,
&ids.project_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
filtered_files.into_iter().collect()
} else {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
all_files.into_iter().collect()
};
profile_files_to_content_items(
let content_items = profile_files_to_content_items(
&profile.path,
&user_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
.await?;
Ok(content_items)
}
/// Pre-fetched metadata for projects, versions, teams, and organizations.
+133 -33
View File
@@ -12,7 +12,7 @@ use dashmap::DashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::convert::TryInto;
use std::path::Path;
@@ -939,41 +939,144 @@ impl Profile {
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
self.get_projects_inner(
cache_behaviour,
pool,
fetch_semaphore,
None,
None,
)
.await
}
pub async fn get_projects_excluding_modpack_files(
&self,
excluded_hashes: &HashSet<String>,
excluded_project_ids: &HashSet<String>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
self.get_projects_inner(
cache_behaviour,
pool,
fetch_semaphore,
Some(excluded_hashes),
Some(excluded_project_ids),
)
.await
}
async fn get_projects_inner(
&self,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
excluded_hashes: Option<&HashSet<String>>,
excluded_project_ids: Option<&HashSet<String>>,
) -> crate::Result<DashMap<String, ProfileFile>> {
let (keys, file_hashes) =
self.scan_and_hash(pool, fetch_semaphore).await?;
let file_updates = file_hashes
.iter()
.map(|x| Self::get_cache_key(x, self))
let excluded_hashes = excluded_hashes.filter(|ids| !ids.is_empty());
let excluded_project_ids =
excluded_project_ids.filter(|ids| !ids.is_empty());
let file_hashes = file_hashes
.into_iter()
.filter(|hash| {
excluded_hashes
.is_none_or(|excluded| !excluded.contains(&hash.hash))
})
.collect::<Vec<_>>();
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let (file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
)?;
let (file_hashes, file_info_by_hash, file_updates) =
if let Some(excluded_project_ids) = excluded_project_ids {
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let mut keys_by_path: std::collections::HashMap<
String,
InitialScanFile,
> = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
let file_info_by_hash: HashMap<String, CachedFile> = file_info
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_info_by_hash: std::collections::HashMap<String, CachedFile> =
file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
let file_hashes = file_hashes
.into_iter()
.filter(|hash| {
file_info_by_hash.get(&hash.hash).is_none_or(|file| {
!excluded_project_ids.contains(&file.project_id)
})
})
.collect::<Vec<_>>();
let file_updates = file_hashes
.iter()
.filter(|x| file_info_by_hash.contains_key(&x.hash))
.map(|x| Self::get_cache_key(x, self))
.collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let file_updates = CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
(file_hashes, file_info_by_hash, file_updates)
} else {
let file_updates = file_hashes
.iter()
.map(|x| Self::get_cache_key(x, self))
.collect::<Vec<_>>();
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let (file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
)?;
let file_info_by_hash: HashMap<String, CachedFile> = file_info
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
(file_hashes, file_info_by_hash, file_updates)
};
let mut keys_by_path: HashMap<String, InitialScanFile> =
keys.into_iter().map(|k| (k.path.clone(), k)).collect();
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
for update in file_updates {
updates_by_hash
.entry(update.hash)
.or_default()
.push(update.update_version_id);
}
let files = DashMap::new();
@@ -989,11 +1092,8 @@ impl Profile {
);
let update_version_id = if let Some(metadata) = &file {
let update_ids: Vec<String> = file_updates
.iter()
.filter(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
.collect();
let update_ids =
updates_by_hash.remove(&hash.hash).unwrap_or_default();
if !update_ids.contains(&metadata.version_id) {
update_ids.into_iter().next()
+64
View File
@@ -10,6 +10,70 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-05-21T22:13:35+00:00`,
product: 'web',
body: `## Fixed
- Fixed project embeds not loading in correctly.`,
},
{
date: `2026-05-21T22:13:35+00:00`,
product: 'app',
version: '0.13.21',
body: `## Fixed
- Fixed issue with content items visually duplicating over other content items in the Content tab table.`,
},
{
date: `2026-05-21T22:13:35+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed issue with content items visually duplicating over other content items in the Content tab table.
- Fixed issue when cancelling a multi-file upload to the Files tab or Content tab, any files fully uploaded before cancelling are not removed on cancel.`,
},
{
date: `2026-05-20T19:48:04+00:00`,
product: 'web',
body: `## Changed
- Improved date selection across the website.
## Fixed
- Fixed server project types sometimes appearing as modpacks in page titles and embeds.`,
},
{
date: `2026-05-20T19:48:04+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed the support portal bubble appearing in the wrong position in the bottom-right corner of the server panel.
- Fixed modpack updates in the Content tab not appearing when a newer Modrinth version was available.`,
},
{
date: `2026-05-20T19:48:04+00:00`,
product: 'app',
version: '0.13.20',
body: `## Changed
- Updated linked modpack version switching to show every available version and ask for confirmation before risky changes:
- Versions for a different game version.
- Downgrades.
## Fixed
- Fixed instance pages sometimes opening with an empty Content tab.
- Fixed Content tab performance problems.
- Fixed linked modpack content appearing as user-added after switching a bundled project to another version.
- Fixed linked modpack content staying stale after updating, removing, enabling, disabling, or switching bundled content.
- Fixed content installation problems from the Browse content page.`,
},
{
date: `2026-05-20T16:01:04.111Z`,
product: 'web',
body: `## Fixed
- Fixed "Permissions" page showing in Modpack settings.`,
},
{
date: `2026-05-19T19:51:55+00:00`,
product: 'web',
body: `## Fixed
- Fixed 404 error when changing project URL.`,
},
{
date: `2026-05-19T18:50:27+00:00`,
product: 'web',
@@ -1,45 +0,0 @@
<template>
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
<div class="flex flex-wrap items-start gap-4 max-md:flex-col">
<div class="flex min-w-0 flex-1 gap-4">
<slot name="icon" />
<div class="flex min-w-0 flex-col gap-2 justify-center">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
</div>
<p
v-if="$slots.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<slot name="stats" />
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 items-center">
<slot name="actions" />
</div>
</div>
<div v-if="$slots.stats" class="flex justify-between md:hidden">
<div class="flex flex-wrap gap-3 empty:hidden">
<slot name="stats" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
disableLineClamp?: boolean
}
const { disableLineClamp } = defineProps<Props>()
</script>
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStack } from '../../composables/modal-stack'
import { injectPageContext } from '../../providers'
const visibleFloatingActionBars = new Set<symbol>()
function updateFloatingActionBarBodyClass() {
if (typeof document === 'undefined') return
document.body.classList.toggle('floating-action-bar-shown', visibleFloatingActionBars.size > 0)
}
const props = defineProps<{
shown: boolean
ariaLabel?: string
@@ -20,6 +28,7 @@ const compact = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCount.value === 0))
const floatingActionBarId = Symbol('floating-action-bar')
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
@@ -60,7 +69,13 @@ function updateIntercomBubbleClearance() {
const intercomBubble = pageContext?.intercomBubble
if (!intercomBubble) return
if (typeof window === 'undefined' || !shown.value || !barEl.value || !toolbarEl.value) {
if (
typeof window === 'undefined' ||
!shown.value ||
stackCount.value > 0 ||
!barEl.value ||
!toolbarEl.value
) {
clearIntercomBubbleClearance()
return
}
@@ -86,7 +101,13 @@ function updateIntercomBubbleClearance() {
function updateBodyState(isShown = shown.value) {
if (typeof document === 'undefined') return
document.body.classList.toggle('floating-action-bar-shown', isShown)
if (isShown) {
visibleFloatingActionBars.add(floatingActionBarId)
} else {
visibleFloatingActionBars.delete(floatingActionBarId)
}
updateFloatingActionBarBodyClass()
if (!isShown) {
clearIntercomBubbleClearance()
}
@@ -138,6 +159,7 @@ watch(
shown,
leftOffset,
rightOffset,
stackCount,
() => pageContext?.intercomBubble?.horizontalPadding.value,
() => pageContext?.intercomBubble?.width.value,
],
@@ -157,8 +179,9 @@ onUnmounted(() => {
window.cancelAnimationFrame(updateFrame)
}
clearIntercomBubbleClearance()
visibleFloatingActionBars.delete(floatingActionBarId)
if (typeof document === 'undefined') return
document.body.classList.remove('floating-action-bar-shown')
updateFloatingActionBarBodyClass()
})
</script>
@@ -31,7 +31,8 @@ import { DropdownIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import { computed } from 'vue'
import { ButtonStyled, OverflowMenu } from '../index'
import ButtonStyled from './ButtonStyled.vue'
import OverflowMenu from './OverflowMenu.vue'
// TODO: This should be moved to a shared types file.
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
+154 -31
View File
@@ -6,40 +6,138 @@
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
<template v-for="(link, index) in filteredLinks" :key="link.href">
<Tooltip
v-if="link.prompt"
theme="dismissable-prompt"
:triggers="[]"
:shown="link.prompt.shown"
:auto-hide="false"
:placement="link.prompt.placement ?? 'bottom'"
>
<RouterLink
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
@click="dismissPrompt(link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ link.prompt.title }}
</h3>
<ButtonStyled size="small" circular>
<button v-tooltip="link.prompt.dismissLabel" @click="link.prompt.onDismiss?.()">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ link.prompt.description }}
</p>
</div>
</template>
</Tooltip>
<RouterLink
v-else
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
</template>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="emit('tabClick', index, link)"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
<template v-for="(link, index) in filteredLinks" :key="link.href">
<Tooltip
v-if="link.prompt"
theme="dismissable-prompt"
:triggers="[]"
:shown="link.prompt.shown"
:auto-hide="false"
:placement="link.prompt.placement ?? 'bottom'"
>
<div
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="handleLocalTabClick(index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ link.prompt.title }}
</h3>
<ButtonStyled size="small" circular>
<button v-tooltip="link.prompt.dismissLabel" @click="link.prompt.onDismiss?.()">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ link.prompt.description }}
</p>
</div>
</template>
</Tooltip>
<div
v-else
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="handleLocalTabClick(index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
</template>
</template>
<!-- Animated slider background -->
@@ -57,12 +155,25 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import ButtonStyled from './ButtonStyled.vue'
const route = useRoute()
interface TabPrompt {
title: string
description: string
dismissLabel?: string
shown?: boolean
placement?: string
onDismiss?: () => void
}
interface Tab {
label: string
href: string
@@ -70,6 +181,7 @@ interface Tab {
icon?: Component
subpages?: string[]
onHover?: () => void
prompt?: TabPrompt
}
const props = withDefaults(
@@ -131,6 +243,17 @@ const isActiveAndNotSubpage = computed(
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
)
function dismissPrompt(link: Tab) {
if (link.prompt?.shown) {
link.prompt.onDismiss?.()
}
}
function handleLocalTabClick(index: number, link: Tab) {
dismissPrompt(link)
emit('tabClick', index, link)
}
function getSSRFallbackClasses(index: number) {
if (sliderReady.value) return {}
if (currentActiveIndex.value !== index) return {}
+675 -7
View File
@@ -1,11 +1,679 @@
<template>
<div class="grid grid-cols-[min-content_1fr_auto] gap-4">
<slot name="icon" />
<div class="flex flex-col gap-1.5">
<slot name="title" />
<slot name="summary" />
<slot name="stats" />
<div
class="flex flex-col gap-2"
:class="[
props.divider ? 'border-0 border-b border-solid border-divider' : '',
props.bottomPadding ? 'pb-4' : '',
props.headerClass,
]"
>
<div class="flex flex-wrap items-start gap-4 max-md:flex-col" :class="props.rowClass">
<div class="flex min-w-0 flex-1 gap-4" :class="props.mainClass">
<div v-if="leadingItems.length" class="contents">
<template v-for="(leadingItem, index) in leadingItems" :key="leadingItem.id ?? index">
<div
v-if="leadingItem.type === 'button'"
:class="
leadingWrapperClass(
leadingItem,
'flex size-16 shrink-0 items-center justify-center',
)
"
>
<ButtonStyled
circular
:color="leadingItem.color ?? 'standard'"
:size="leadingItem.size ?? 'large'"
:type="leadingItem.buttonType ?? 'standard'"
>
<AutoLink
v-if="leadingItem.to"
v-tooltip="leadingItem.tooltip"
:to="leadingItem.to"
:aria-label="leadingLabel(leadingItem)"
>
<component
:is="leadingItem.icon"
v-if="leadingItem.icon"
aria-hidden="true"
v-bind="leadingItem.iconProps"
/>
</AutoLink>
<button
v-else
v-tooltip="leadingItem.tooltip"
type="button"
:aria-label="leadingLabel(leadingItem)"
@click="leadingItem.onClick"
>
<component
:is="leadingItem.icon"
v-if="leadingItem.icon"
aria-hidden="true"
v-bind="leadingItem.iconProps"
/>
</button>
</ButtonStyled>
</div>
<Avatar
v-else-if="leadingItem.type === 'avatar'"
:src="leadingItem.src"
:alt="leadingItem.alt ?? ''"
:size="leadingItem.avatarSize ?? '64px'"
:tint-by="leadingItem.tintBy ?? null"
:circle="leadingItem.circle ?? false"
:no-shadow="leadingItem.noShadow ?? false"
:raised="leadingItem.raised ?? false"
:loading="leadingItem.loading ?? 'eager'"
:class="leadingItem.class"
/>
<component
:is="leadingItem.component"
v-else-if="leadingItem.component"
:class="leadingItem.class"
v-bind="leadingItem.componentProps"
/>
</template>
</div>
<div class="flex min-w-0 flex-col gap-2 justify-center">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1
class="m-0 min-w-0 max-w-full text-2xl font-semibold leading-none text-contrast"
:class="[props.truncateTitle ? 'truncate' : '', props.titleClass]"
>
{{ props.header }}
</h1>
<template v-for="badge in props.badges" :key="badge.id">
<component
:is="badge.component"
v-if="badge.component"
:class="badge.class"
v-bind="badge.componentProps"
/>
<AutoLink
v-else-if="badge.to"
v-tooltip="badge.tooltip"
:to="badge.to"
:aria-label="badge.ariaLabel ?? badge.label"
:class="badgeClass(badge, true)"
:style="badge.style"
>
<component
:is="badge.icon"
v-if="badge.icon"
aria-hidden="true"
v-bind="badge.iconProps"
/>
<span>{{ badge.label }}</span>
</AutoLink>
<button
v-else-if="badge.onClick"
v-tooltip="badge.tooltip"
type="button"
:aria-label="badge.ariaLabel ?? badge.label"
:class="badgeClass(badge, true)"
:style="badge.style"
@click="badge.onClick"
>
<component
:is="badge.icon"
v-if="badge.icon"
aria-hidden="true"
v-bind="badge.iconProps"
/>
<span>{{ badge.label }}</span>
</button>
<div
v-else
v-tooltip="badge.tooltip"
:aria-label="badge.ariaLabel"
:class="badgeClass(badge)"
:style="badge.style"
>
<component
:is="badge.icon"
v-if="badge.icon"
aria-hidden="true"
v-bind="badge.iconProps"
/>
<span>{{ badge.label }}</span>
</div>
</template>
</div>
<p
v-if="props.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[props.disableLineClamp ? '' : 'line-clamp-2']"
>
{{ props.summary }}
</p>
</div>
<div v-if="props.metadata.length" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<template v-for="(item, index) in props.metadata" :key="item.id">
<BulletDivider v-if="index > 0" class="shrink-0" />
<div v-if="item.type === 'custom'" :class="item.class">
<slot :name="metadataSlotName(item)" :item="item" />
</div>
<AutoLink
v-else-if="item.to"
v-tooltip="item.tooltip"
:to="item.to"
:aria-label="item.ariaLabel ?? item.label ?? ''"
:class="metadataClass(item, true)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</AutoLink>
<button
v-else-if="item.onClick"
v-tooltip="item.tooltip"
type="button"
:aria-label="item.ariaLabel ?? item.label ?? ''"
:class="metadataClass(item, true)"
@click="item.onClick"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</button>
<div
v-else
v-tooltip="item.tooltip"
:aria-label="item.ariaLabel"
:class="metadataClass(item)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</div>
</div>
<div v-if="props.actions.length" class="flex flex-wrap gap-2 items-center">
<template v-for="action in props.actions" :key="action.id">
<component
:is="action.component"
v-if="action.component"
:class="action.class"
v-bind="action.componentProps"
/>
<Tooltip
v-else-if="action.prompt"
theme="dismissable-prompt"
:triggers="[]"
:shown="action.prompt.shown"
:auto-hide="false"
:placement="action.prompt.placement ?? 'bottom'"
>
<JoinedButtons
v-if="action.joinedActions?.length"
:actions="action.joinedActions"
:color="joinedActionColor(action.color)"
:size="action.size ?? 'large'"
:disabled="action.disabled"
:primary-disabled="action.primaryDisabled"
:dropdown-disabled="action.dropdownDisabled"
:primary-muted="action.primaryMuted"
/>
<ButtonStyled
v-else
:color="action.color ?? 'standard'"
:size="action.size ?? 'large'"
:type="action.type ?? 'standard'"
:circular="action.circular ?? action.labelHidden ?? false"
>
<TeleportOverflowMenu
v-if="action.menuActions?.length"
:options="action.menuActions"
:tooltip="action.tooltip"
:aria-label="actionLabel(action)"
:disabled="action.disabled"
@open="dismissActionPrompt(action)"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</TeleportOverflowMenu>
<AutoLink
v-else-if="action.to"
v-tooltip="action.tooltip"
:to="action.to"
:aria-label="actionLabel(action)"
@click="(event) => handleActionClick(action, event)"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</AutoLink>
<button
v-else
v-tooltip="action.tooltip"
type="button"
:disabled="action.disabled"
:aria-label="actionLabel(action)"
@click="(event) => handleActionClick(action, event)"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</button>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3
class="m-0 flex items-center gap-2 whitespace-nowrap text-base font-bold text-contrast"
>
{{ action.prompt.title }}
<span
v-if="action.prompt.badge"
class="inline-flex items-center rounded-full border border-solid border-brand-highlight bg-brand-highlight px-2 py-1 text-sm font-semibold leading-none text-brand"
>
{{ action.prompt.badge }}
</span>
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="action.prompt.dismissLabel"
@click="action.prompt.onDismiss?.()"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ action.prompt.description }}
</p>
<p v-if="action.prompt.footer" class="m-0 text-wrap text-sm font-bold text-primary">
{{ action.prompt.footer }}
</p>
</div>
</template>
</Tooltip>
<template v-else>
<JoinedButtons
v-if="action.joinedActions?.length"
:actions="action.joinedActions"
:color="joinedActionColor(action.color)"
:size="action.size ?? 'large'"
:disabled="action.disabled"
:primary-disabled="action.primaryDisabled"
:dropdown-disabled="action.dropdownDisabled"
:primary-muted="action.primaryMuted"
/>
<ButtonStyled
v-else
:color="action.color ?? 'standard'"
:size="action.size ?? 'large'"
:type="action.type ?? 'standard'"
:circular="action.circular ?? action.labelHidden ?? false"
>
<TeleportOverflowMenu
v-if="action.menuActions?.length"
:options="action.menuActions"
:tooltip="action.tooltip"
:aria-label="actionLabel(action)"
:disabled="action.disabled"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</TeleportOverflowMenu>
<AutoLink
v-else-if="action.to"
v-tooltip="action.tooltip"
:to="action.to"
:aria-label="actionLabel(action)"
@click="(event) => handleActionClick(action, event)"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</AutoLink>
<button
v-else
v-tooltip="action.tooltip"
type="button"
:disabled="action.disabled"
:aria-label="actionLabel(action)"
@click="(event) => handleActionClick(action, event)"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</button>
</ButtonStyled>
</template>
</template>
</div>
</div>
<div v-if="props.metadata.length" class="flex justify-between md:hidden">
<div class="flex flex-wrap gap-3 empty:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<template v-for="(item, index) in props.metadata" :key="item.id">
<BulletDivider v-if="index > 0" class="shrink-0" />
<div v-if="item.type === 'custom'" :class="item.class">
<slot :name="metadataSlotName(item)" :item="item" />
</div>
<AutoLink
v-else-if="item.to"
v-tooltip="item.tooltip"
:to="item.to"
:aria-label="item.ariaLabel ?? item.label ?? ''"
:class="metadataClass(item, true)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</AutoLink>
<button
v-else-if="item.onClick"
v-tooltip="item.tooltip"
type="button"
:aria-label="item.ariaLabel ?? item.label ?? ''"
:class="metadataClass(item, true)"
@click="item.onClick"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</button>
<div
v-else
v-tooltip="item.tooltip"
:aria-label="item.ariaLabel"
:class="metadataClass(item)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</div>
<slot name="actions" />
</div>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AutoLink from './AutoLink.vue'
import Avatar from './Avatar.vue'
import BulletDivider from './BulletDivider.vue'
import ButtonStyled from './ButtonStyled.vue'
import JoinedButtons, { type JoinedButtonAction } from './JoinedButtons.vue'
import TeleportOverflowMenu, {
type Item as TeleportOverflowMenuItem,
} from './TeleportOverflowMenu.vue'
type PageHeaderTarget = string | RouteLocationRaw
type ButtonColor =
| 'standard'
| 'brand'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'medal-promo'
type ButtonSize = 'standard' | 'large' | 'small'
type ButtonType =
| 'standard'
| 'outlined'
| 'transparent'
| 'highlight'
| 'highlight-colored-text'
| 'chip'
type PageHeaderLeading = {
id?: string
type: 'avatar' | 'button' | 'component'
icon?: Component
iconProps?: Record<string, unknown>
component?: Component
componentProps?: Record<string, unknown>
src?: string | null
alt?: string
tintBy?: string | null
avatarSize?: string
circle?: boolean
noShadow?: boolean
raised?: boolean
loading?: 'eager' | 'lazy'
class?: string
wrapperClass?: string
to?: PageHeaderTarget
onClick?: (event?: MouseEvent) => void | Promise<void>
tooltip?: string
ariaLabel?: string
color?: ButtonColor
size?: ButtonSize
buttonType?: ButtonType
}
type PageHeaderBadge = {
id: string
label?: string
icon?: Component
iconProps?: Record<string, unknown>
component?: Component
componentProps?: Record<string, unknown>
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
onClick?: () => void | Promise<void>
class?: string
style?: Record<string, string>
}
type PageHeaderMetadataItem = {
id: string
type?: 'text' | 'custom'
label?: string
icon?: Component
iconProps?: Record<string, unknown>
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
onClick?: () => void | Promise<void>
class?: string
}
type PageHeaderActionPrompt = {
title: string
description: string
badge?: string
footer?: string
dismissLabel?: string
shown?: boolean
placement?: string
onDismiss?: () => void
}
type PageHeaderAction = {
id: string
label: string
component?: Component
componentProps?: Record<string, unknown>
class?: string
icon?: Component
iconProps?: Record<string, unknown>
iconClass?: string
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
onClick?: () => void | Promise<void>
disabled?: boolean
labelHidden?: boolean
circular?: boolean
color?: ButtonColor
size?: ButtonSize
type?: ButtonType
joinedActions?: JoinedButtonAction[]
menuActions?: TeleportOverflowMenuItem[]
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
prompt?: PageHeaderActionPrompt
}
const props = withDefaults(
defineProps<{
header: string
summary?: string | null
leading?: PageHeaderLeading | PageHeaderLeading[] | null
badges?: PageHeaderBadge[]
metadata?: PageHeaderMetadataItem[]
actions?: PageHeaderAction[]
headerClass?: string
rowClass?: string
mainClass?: string
titleClass?: string
truncateTitle?: boolean
divider?: boolean
bottomPadding?: boolean
disableLineClamp?: boolean
}>(),
{
summary: null,
leading: null,
badges: () => [],
metadata: () => [],
actions: () => [],
headerClass: '',
rowClass: '',
mainClass: '',
titleClass: '',
truncateTitle: false,
divider: true,
bottomPadding: true,
disableLineClamp: false,
},
)
const leadingItems = computed(() => {
if (!props.leading) return []
return Array.isArray(props.leading) ? props.leading : [props.leading]
})
function leadingLabel(item: PageHeaderLeading) {
return item.ariaLabel ?? item.tooltip ?? 'Header action'
}
function leadingWrapperClass(item: PageHeaderLeading, defaultClass: string) {
return [item.wrapperClass ?? defaultClass]
}
function metadataClass(item: PageHeaderMetadataItem, interactive = false) {
return [
'flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap',
interactive ? 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline' : '',
item.class,
]
}
function metadataSlotName(item: PageHeaderMetadataItem) {
return `metadata-${item.id}`
}
function badgeClass(badge: PageHeaderBadge, interactive = false) {
return [
'inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-button-bg px-2 py-1 text-sm font-semibold leading-none text-secondary text-nowrap [&>svg]:size-4 [&>svg]:shrink-0',
interactive ? 'm-0 cursor-pointer hover:underline' : '',
badge.class,
]
}
function actionLabel(action: PageHeaderAction) {
return action.ariaLabel ?? action.tooltip ?? action.label
}
function dismissActionPrompt(action: PageHeaderAction) {
if (action.prompt?.shown) {
action.prompt.onDismiss?.()
}
}
function handleActionClick(action: PageHeaderAction, event?: MouseEvent) {
dismissActionPrompt(action)
void action.onClick?.(event)
}
function joinedActionColor(color?: ButtonColor) {
return color === 'medal-promo' ? 'standard' : color
}
</script>
@@ -2,10 +2,13 @@
<div data-pyro-telepopover-wrapper class="relative">
<button
ref="triggerRef"
v-tooltip="tooltip"
class="teleport-overflow-menu-trigger"
:class="btnClass"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="ariaLabel"
:disabled="disabled"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@@ -38,9 +41,14 @@
:key="isDivider(option) ? `divider-${index}` : option.id"
>
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
<ButtonStyled
v-else
type="transparent"
role="menuitem"
:color="optionButtonColor(option)"
>
<button
v-if="typeof option.action === 'function'"
v-if="typeof option.action === 'function' || option.disabled"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
@@ -51,39 +59,41 @@
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@click="(event) => handleItemClick(option, index, event)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
{{ option.label ?? option.id }}
</slot>
</button>
<AutoLink
v-else-if="typeof option.action === 'string'"
v-else-if="optionLink(option)"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:to="option.action"
:to="optionLink(option)"
:target="option.external ? '_blank' : undefined"
:rel="option.external ? 'noopener noreferrer' : undefined"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@click="(event) => handleItemClick(option, index, event)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
{{ option.label ?? option.id }}
</slot>
</AutoLink>
<span v-else>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
{{ option.label ?? option.id }}
</slot>
</span>
</ButtonStyled>
@@ -95,29 +105,48 @@
</template>
<script setup lang="ts">
import { AutoLink, ButtonStyled } from '@modrinth/ui'
import { onClickOutside, useElementHover } from '@vueuse/core'
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
interface Option {
import AutoLink from './AutoLink.vue'
import ButtonStyled from './ButtonStyled.vue'
type OptionColor =
| 'standard'
| 'brand'
| 'primary'
| 'danger'
| 'secondary'
| 'highlight'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
export interface Option {
id: string
label?: string
icon?: Component
action?: (() => void) | string
action?: ((event?: MouseEvent) => void) | string
link?: string
external?: boolean
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
color?: OptionColor
disabled?: boolean
tooltip?: string
remainOnClick?: boolean
}
type Divider = {
export type Divider = {
divider?: boolean
shown?: boolean
}
type Item = Option | Divider
export type Item = Option | Divider
function isDivider(item: Item): item is Divider {
return (item as Divider).divider
return !!(item as Divider).divider
}
const props = withDefaults(
@@ -125,10 +154,16 @@ const props = withDefaults(
options: Item[]
hoverable?: boolean
btnClass?: string | string[] | Record<string, boolean>
disabled?: boolean
tooltip?: string
ariaLabel?: string
}>(),
{
hoverable: false,
btnClass: undefined,
disabled: false,
tooltip: undefined,
ariaLabel: undefined,
},
)
@@ -191,6 +226,7 @@ const calculateMenuPosition = () => {
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation()
if (props.disabled) return
if (!props.hoverable) {
if (isOpen.value) {
closeMenu()
@@ -201,6 +237,7 @@ const toggleMenu = (event: MouseEvent) => {
}
const openMenu = () => {
if (props.disabled) return
isOpen.value = true
emit('open')
disableBodyScroll()
@@ -218,15 +255,18 @@ const closeMenu = () => {
document.removeEventListener('mousemove', handleMouseMove)
}
const selectOption = (option: Option) => {
const selectOption = (option: Option, event?: MouseEvent) => {
emit('select', option)
if (typeof option.action === 'function') {
option.action()
option.action(event)
}
if (!option.remainOnClick) {
closeMenu()
}
closeMenu()
}
const handleMouseDown = (event: MouseEvent) => {
if (props.disabled) return
event.preventDefault()
isMouseDown.value = true
}
@@ -270,10 +310,10 @@ const handleMouseMove = (event: MouseEvent) => {
}
}
const handleItemClick = (option: Option, index: number) => {
const handleItemClick = (option: Option, index: number, event?: MouseEvent) => {
if (option.disabled) return
selectedIndex.value = index
selectOption(option)
selectOption(option, event)
}
const handleMouseOver = (index: number) => {
@@ -432,4 +472,23 @@ onClickOutside(menuRef, (event) => {
closeMenu()
}
})
function optionLink(option: Option) {
if (typeof option.action === 'string') return option.action
return option.link
}
function optionButtonColor(option: Option) {
switch (option.color) {
case 'primary':
return 'brand'
case 'danger':
return 'red'
case 'secondary':
case 'highlight':
return 'standard'
default:
return option.color ?? 'standard'
}
}
</script>
+6 -1
View File
@@ -19,7 +19,6 @@ export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DatePicker } from './DatePicker.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
@@ -59,6 +58,7 @@ export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as PageHeader } from './PageHeader.vue'
export { default as Pagination } from './Pagination.vue'
export { default as PopoutMenu } from './PopoutMenu.vue'
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
@@ -83,6 +83,11 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export type {
Item as TeleportOverflowMenuItem,
Option as TeleportOverflowMenuOption,
} from './TeleportOverflowMenu.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
@@ -1,18 +1,14 @@
<template>
<ContentPageHeader>
<template #icon>
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
</template>
<template #title>
{{ project.title }}
</template>
<template #title-suffix>
<ProjectStatusBadge v-if="member || project.status !== 'approved'" :status="project.status" />
</template>
<template #summary>
{{ project.description }}
</template>
<template #stats>
<PageHeader
:header="project.title"
:summary="project.description"
:leading="leadingItem"
:badges="headerBadges"
:metadata="headerMetadata"
:actions="actions"
:disable-line-clamp="disableLineClamp"
>
<template #metadata-project-stats>
<div class="flex items-center gap-3 flex-wrap gap-y-0">
<template v-if="isServerProject">
<ServerDetails
@@ -66,24 +62,24 @@
</div>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { capitalizeString, type Project } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useRouter } from 'vue-router'
import { useCompactNumber, useVIntl } from '../../composables'
import { commonMessages } from '../../utils'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import FormattedTag from '../base/FormattedTag.vue'
import type { JoinedButtonAction } from '../base/JoinedButtons.vue'
import PageHeader from '../base/PageHeader.vue'
import TagItem from '../base/TagItem.vue'
import type { Item as TeleportOverflowMenuItem } from '../base/TeleportOverflowMenu.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
import ServerDetails from './server/ServerDetails.vue'
@@ -91,18 +87,77 @@ const router = useRouter()
const { formatMessage } = useVIntl()
const { formatCompactNumber } = useCompactNumber()
type HeaderAction = {
id: string
label: string
component?: Component
componentProps?: Record<string, unknown>
class?: string
icon?: Component
iconProps?: Record<string, unknown>
iconClass?: string
tooltip?: string
ariaLabel?: string
to?: string | RouteLocationRaw
onClick?: (event?: MouseEvent) => void | Promise<void>
disabled?: boolean
labelHidden?: boolean
circular?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
size?: 'standard' | 'large' | 'small'
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
joinedActions?: JoinedButtonAction[]
menuActions?: TeleportOverflowMenuItem[]
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
}
const props = withDefaults(
defineProps<{
project: Project
member?: boolean
projectV3?: Labrinth.Projects.v3.Project | null
ping?: number
actions?: HeaderAction[]
disableLineClamp?: boolean
}>(),
{
member: false,
actions: () => [],
disableLineClamp: false,
},
)
const leadingItem = computed(() => ({
type: 'avatar' as const,
src: props.project.icon_url,
alt: props.project.title,
avatarSize: '96px',
}))
const headerBadges = computed(() =>
props.member || props.project.status !== 'approved'
? [
{
id: 'status',
component: ProjectStatusBadge,
componentProps: {
status: props.project.status,
},
},
]
: [],
)
const headerMetadata = [
{
id: 'project-stats',
type: 'custom' as const,
class: 'contents',
},
]
const searchUrl = computed(
() => `/discover/${isServerProject.value ? 'servers' : `${props.project.project_type}s`}`,
)
@@ -77,7 +77,7 @@ onMounted(() => {
isClient.value = true
})
const { serverId } = injectModrinthServerContext()
const { serverId, worldId } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
const props = withDefaults(
@@ -190,7 +190,9 @@ const metrics = computed(() => {
showGraph: false,
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
series: null as { name: string; data: number[] }[] | null,
link: `/hosting/manage/${encodeURIComponent(serverId)}/files`,
link: worldId.value
? `/hosting/manage/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(worldId.value)}/files`
: `/hosting/manage/${encodeURIComponent(serverId)}/instances`,
}
if (props.loading) {
@@ -186,7 +186,7 @@ async function show({ serverId, tabIndex, tabId }: ShowOptions) {
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
})
queryClient.prefetchQuery({
queryKey: ['content', 'list', 'v1', targetServerId],
queryKey: ['content', 'list', 'v1', targetServerId, worldId.value],
queryFn: () =>
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
from_modpack: false,
@@ -11,7 +11,7 @@ import InstallingBanner, {
type SyncProgress,
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -53,8 +53,12 @@ const messages = defineMessages({
},
})
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const isOnContentTab = computed(
() =>
route.path.includes('/content') ||
(!!route.params.instance_id && !isOnFilesTab.value && !route.path.includes('/backups')),
)
const bannerCoversInstalling = computed(
() =>
@@ -92,6 +96,7 @@ const filesBusyHeader = computed(() =>
const dismissedIds = reactive(new Set<string>())
const cancellingIds = reactive(new Set<string>())
const uploadCancelling = ref(false)
const dismissedContentErrorKey = ref<string | null>(null)
const contentErrorKey = computed(() =>
@@ -327,6 +332,21 @@ async function onBackupRetry(item: BackupAdmonitionEntry) {
await invalidate()
}
async function onUploadCancel() {
if (uploadCancelling.value) return
const cancel = ctx.cancelUpload.value
if (!cancel) return
uploadCancelling.value = true
try {
await cancel()
} catch (err) {
console.error('Failed to cancel upload', err)
} finally {
uploadCancelling.value = false
}
}
async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
@@ -375,7 +395,12 @@ function onContentErrorDismiss() {
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
<UploadAdmonition v-else-if="item.kind === 'upload'" />
<UploadAdmonition
v-else-if="item.kind === 'upload'"
:cancelable="!!ctx.cancelUpload.value"
:cancelling="uploadCancelling"
@cancel="onUploadCancel"
/>
<FileOperationAdmonition
v-else-if="item.kind === 'fs-op'"
:op="item.op"
@@ -15,9 +15,11 @@
Math.round(overallProgress * 100)
}}%)
</span>
<template v-if="cancelUpload" #top-right-actions>
<template v-if="cancelable" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
Cancel
</button>
</ButtonStyled>
</template>
</Admonition>
@@ -32,12 +34,26 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext } from '#ui/providers'
withDefaults(
defineProps<{
cancelable?: boolean
cancelling?: boolean
}>(),
{
cancelable: true,
cancelling: false,
},
)
defineEmits<{
cancel: []
}>()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const state = computed(() => ctx.uploadState.value)
const cancelUpload = computed(() => ctx.cancelUpload.value)
const overallProgress = computed(() => {
const s = state.value
@@ -87,12 +87,12 @@ const props = defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
const createMutation = useMutation({
mutationFn: (name: string) =>
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
})
const modal = ref<InstanceType<typeof NewModal>>()
@@ -69,12 +69,12 @@ const props = defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
})
const modal = ref<InstanceType<typeof NewModal>>()

Some files were not shown because too many files have changed in this diff Show More