mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4f913605 | ||
|
|
8e61f042b7 | ||
|
|
8301620b76 | ||
|
|
3a69e8818a | ||
|
|
8944437f1b | ||
|
|
65e250f7f2 | ||
|
|
5e34efea22 | ||
|
|
e36f8a42f5 | ||
|
|
c5f1fa1154 | ||
|
|
443e17f9d5 | ||
|
|
7622abe59b | ||
|
|
76036602a3 | ||
|
|
e2ac1274ac | ||
|
|
ceef2811f4 | ||
|
|
739a1baef5 | ||
|
|
1e1a75ab90 | ||
|
|
b6f25e9ed4 | ||
|
|
ef4c2e3161 | ||
|
|
7911ce8e4a | ||
|
|
fcaaf534a8 | ||
|
|
3cd18b7205 | ||
|
|
f9d47e8edc | ||
|
|
a58bc3dc21 | ||
|
|
1e46444fb0 | ||
|
|
1511e55597 | ||
|
|
5727e156ed | ||
|
|
657186398d | ||
|
|
3ab2273782 | ||
|
|
893ec00fc6 | ||
|
|
aa7dd1d210 | ||
|
|
f8733b0488 | ||
|
|
d077d44540 | ||
|
|
4e1a61d8b6 | ||
|
|
71dee4de40 | ||
|
|
f74fad0cae | ||
|
|
07e81ac036 | ||
|
|
6e7835fb35 | ||
|
|
2f95c4c441 | ||
|
|
451b2d0e44 | ||
|
|
215643c846 | ||
|
|
d8b1415f9c | ||
|
|
3eeb549d20 | ||
|
|
c3fe7b4232 | ||
|
|
079a10bba9 | ||
|
|
3c3d5702ba | ||
|
|
a34576a2c3 | ||
|
|
d8e4915a31 |
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"types": ["vite/client"],
|
||||
|
||||
"paths": {
|
||||
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+1
-1
@@ -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,
|
||||
})
|
||||
+4
-2
@@ -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 {
|
||||
+1
-1
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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],
|
||||
() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+31
@@ -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"
|
||||
}
|
||||
Generated
+36
@@ -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"
|
||||
}
|
||||
Generated
-24
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}`,
|
||||
)
|
||||
|
||||
+4
-2
@@ -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
Reference in New Issue
Block a user