From 21fb17ad2e42f0d58d627b1d9030febd29aa0c70 Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Mon, 3 Aug 2026 07:56:03 +0100
Subject: [PATCH 01/13] feat: server play frontend draft
---
.../src/composables/use-app-settings.ts | 1 +
.../src/pages/hosting/manage/Index.vue | 1 +
.../src/pages/hosting/manage/Play.vue | 18 ++
.../src/pages/hosting/manage/index.js | 3 +-
apps/app-frontend/src/routes.js | 5 +
apps/frontend/src/composables/featureFlags.ts | 1 +
.../src/pages/hosting/manage/[id].vue | 1 +
.../src/pages/hosting/manage/[id]/play.vue | 19 ++
.../manage/[id]/play/ServerPlayCard.vue | 175 +++++++++++++
.../manage/[id]/play/ServerPlayersTable.vue | 200 ++++++++++++++
.../wrapped/hosting/manage/[id]/play/play.vue | 245 ++++++++++++++++++
.../wrapped/hosting/manage/[id]/play/types.ts | 11 +
.../layouts/wrapped/hosting/manage/root.vue | 13 +
packages/ui/src/layouts/wrapped/index.ts | 1 +
14 files changed, 693 insertions(+), 1 deletion(-)
create mode 100644 apps/app-frontend/src/pages/hosting/manage/Play.vue
create mode 100644 apps/frontend/src/pages/hosting/manage/[id]/play.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 0d58903fa12..9170b991820 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -23,6 +23,7 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_offline_collapsed: true,
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
+ ServerPlayFrontend: false,
localhost_sign_in: false,
}
diff --git a/apps/app-frontend/src/pages/hosting/manage/Index.vue b/apps/app-frontend/src/pages/hosting/manage/Index.vue
index e182d1386fc..2a805918683 100644
--- a/apps/app-frontend/src/pages/hosting/manage/Index.vue
+++ b/apps/app-frontend/src/pages/hosting/manage/Index.vue
@@ -9,6 +9,7 @@
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="appSettings.devMode"
+ :show-play-tab="appSettings.getFeatureFlag('ServerPlayFrontend')"
:auth-user="authUser"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
diff --git a/apps/app-frontend/src/pages/hosting/manage/Play.vue b/apps/app-frontend/src/pages/hosting/manage/Play.vue
new file mode 100644
index 00000000000..3728c9ceb07
--- /dev/null
+++ b/apps/app-frontend/src/pages/hosting/manage/Play.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js
index 0c10b07133d..073c5492961 100644
--- a/apps/app-frontend/src/pages/hosting/manage/index.js
+++ b/apps/app-frontend/src/pages/hosting/manage/index.js
@@ -4,5 +4,6 @@ import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
+import Play from './Play.vue'
-export { Access, Backups, Content, Files, Index, Overview }
+export { Access, Backups, Content, Files, Index, Overview, Play }
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index d94d13109d2..4ec2ce94fe0 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -20,6 +20,11 @@ export default new createRouter({
name: 'ServerManage',
component: () => import('@/pages/hosting/manage/Index.vue'),
children: [
+ {
+ path: 'play',
+ name: 'ServerManagePlay',
+ component: Hosting.Play,
+ },
{
path: '',
name: 'ServerManageOverview',
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts
index 1165ef04cca..e7300aec278 100644
--- a/apps/frontend/src/composables/featureFlags.ts
+++ b/apps/frontend/src/composables/featureFlags.ts
@@ -59,6 +59,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
dismissedPhotosensitivityFilterWarning: false,
+ ServerPlayFrontend: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue
index 5f6d5245f42..9e218386abf 100644
--- a/apps/frontend/src/pages/hosting/manage/[id].vue
+++ b/apps/frontend/src/pages/hosting/manage/[id].vue
@@ -5,6 +5,7 @@
:resolve-viewer="resolveViewer"
:show-copy-id-action="flags.developerMode"
:show-advanced-debug-info="flags.advancedDebugInfo"
+ :show-play-tab="flags.ServerPlayFrontend"
:stripe-publishable-key="config.public.stripePublishableKey as string"
:site-url="config.public.siteUrl as string"
:products="products"
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/play.vue b/apps/frontend/src/pages/hosting/manage/[id]/play.vue
new file mode 100644
index 00000000000..b4866d98f76
--- /dev/null
+++ b/apps/frontend/src/pages/hosting/manage/[id]/play.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
new file mode 100644
index 00000000000..b5159719929
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+ {{ formatMessage(messages.playWithAppTitle) }}
+
+
+ {{ formatMessage(messages.playWithAppDescription) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.differentLauncherTitle) }}
+
+
+ {{ formatMessage(messages.differentLauncherDescription) }}
+
+
+
+
+
+ {{ address }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
new file mode 100644
index 00000000000..f2359226557
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No users match your filters.
+
+
+
+
+
+
+
+ {{ formatPlayerDate(row.lastPlayedAt) }}
+
+ Never
+
+
+
+ Pending
+
+
+ {{ formatPlayerDate(row.joinedAt) }}
+
+
+
+
+
+
+ {{ methodLabel(row.method) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
new file mode 100644
index 00000000000..0c9fd04c2da
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+ {{ formatMessage(messages.invitedPlayersTitle) }}
+
+ props.onOpenPlayerActions?.(player)"
+ />
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
new file mode 100644
index 00000000000..63f2dbb0550
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
@@ -0,0 +1,11 @@
+export type ServerPlayerMethod = 'direct' | 'link'
+
+export type ServerPlayerRow = {
+ id: string
+ username: string
+ avatarUrl?: string
+ lastPlayedAt: Date | null
+ joinedAt: Date | null
+ method: ServerPlayerMethod
+ pending?: boolean
+}
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
index 9ccd656e70b..84bc3b13ba1 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -317,6 +317,7 @@ import {
LinkIcon,
LoaderCircleIcon,
LockIcon,
+ PlayIcon,
MoreVerticalIcon,
ServerIcon as ServerAssetIcon,
SettingsIcon,
@@ -394,6 +395,7 @@ const props = withDefaults(
showCopyIdAction?: boolean
showAdvancedDebugInfo?: boolean
showUptime?: boolean
+ showPlayTab?: boolean
additionalTabs?: Tab[]
stripePublishableKey?: string
siteUrl?: string
@@ -418,6 +420,7 @@ const props = withDefaults(
showCopyIdAction: false,
showAdvancedDebugInfo: false,
showUptime: true,
+ showPlayTab: false,
additionalTabs: () => [],
stripePublishableKey: undefined,
siteUrl: undefined,
@@ -775,6 +778,16 @@ watch(serverData, (data) => {
})
const navLinks = computed(() => [
+ ...(props.showPlayTab
+ ? [
+ {
+ label: 'Play',
+ href: `/hosting/manage/${props.serverId}/play`,
+ icon: PlayIcon,
+ subpages: [],
+ },
+ ]
+ : []),
{
label: 'Overview',
href: `/hosting/manage/${props.serverId}`,
diff --git a/packages/ui/src/layouts/wrapped/index.ts b/packages/ui/src/layouts/wrapped/index.ts
index 59ba8d71097..c4ddd4de573 100644
--- a/packages/ui/src/layouts/wrapped/index.ts
+++ b/packages/ui/src/layouts/wrapped/index.ts
@@ -5,5 +5,6 @@ export { default as ServersManageContentPage } from './hosting/manage/content.vu
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
+export { default as ServersManagePlayPage } from './hosting/manage/[id]/play/play.vue'
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
export * from './settings'
From 5bb26b5f569b36cadcfba5c314c5298a6ade846f Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Fri, 4 Sep 2026 15:30:06 +0100
Subject: [PATCH 02/13] fix: build error
---
.../manage/[id]/play/ServerPlayCard.vue | 95 +++++++++----------
.../manage/[id]/play/ServerPlayersTable.vue | 26 ++---
2 files changed, 57 insertions(+), 64 deletions(-)
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
index b5159719929..3fa2fb5bc0c 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
@@ -12,18 +12,14 @@
-
-
-
-
-
-
+
+
@@ -38,44 +34,41 @@
-
+
+
+
+ {{ formatMessage(messages.downloadModpackButton) }}
+
+
+
+
+
+
+
+
+
@@ -107,7 +100,7 @@ import {
} from '@modrinth/assets'
import { ref } from 'vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { Button, ButtonGroup, ButtonLink, IconButton } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const props = defineProps<{
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
index f2359226557..56730841e2d 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
@@ -75,18 +75,18 @@
-
-
-
+
+
+
+
@@ -105,7 +105,7 @@ import {
import { computed, ref } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
-import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
+import { IconButton } from '#ui/components/base/buttons'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import Table, { type SortDirection, type TableColumn } from '#ui/components/base/Table.vue'
From e0517718de5181416eebadb4c9384ac630139c17 Mon Sep 17 00:00:00 2001
From: "Calum H."
Date: Fri, 4 Sep 2026 15:33:02 +0100
Subject: [PATCH 03/13] feat: start on content tab env focused design (#7386)
---
apps/app-frontend/src/pages/Browse.vue | 16 +-
.../src/pages/discover/[type]/index.vue | 16 +-
.../src/modules/archon/content/v1.ts | 51 ++
.../api-client/src/modules/archon/types.ts | 32 ++
.../src/modules/kyros/content/v1.ts | 18 +
packages/ui/src/components/base/Toggle.vue | 28 +-
.../ui/src/composables/server-panel-sync.ts | 51 +-
packages/ui/src/composables/virtual-scroll.ts | 13 +-
.../components/ContentCardItem.vue | 59 ++-
.../components/ContentCardItemIcon.vue | 91 ++++
.../components/ContentCardTable.vue | 66 ++-
.../components/ContentEnabledFor.vue | 112 +++++
.../managed-content-modal/index.vue | 97 +++-
.../src/layouts/shared/content-tab/layout.vue | 104 ++--
.../content-tab/providers/content-manager.ts | 4 +-
.../src/layouts/shared/content-tab/types.ts | 18 +
.../server-settings/pages/installation.vue | 43 +-
.../wrapped/hosting/manage/content.vue | 465 ++++++++++++------
packages/ui/src/locales/cs-CZ/index.json | 9 -
packages/ui/src/locales/de-CH/index.json | 9 -
packages/ui/src/locales/de-DE/index.json | 9 -
packages/ui/src/locales/en-US/index.json | 49 +-
packages/ui/src/locales/es-419/index.json | 9 -
packages/ui/src/locales/es-ES/index.json | 9 -
packages/ui/src/locales/fil-PH/index.json | 7 -
packages/ui/src/locales/fr-FR/index.json | 9 -
packages/ui/src/locales/hu-HU/index.json | 6 -
packages/ui/src/locales/it-IT/index.json | 9 -
packages/ui/src/locales/ja-JP/index.json | 9 -
packages/ui/src/locales/ko-KR/index.json | 9 -
packages/ui/src/locales/ms-MY/index.json | 10 -
packages/ui/src/locales/nl-NL/index.json | 9 -
packages/ui/src/locales/pl-PL/index.json | 9 -
packages/ui/src/locales/pt-BR/index.json | 10 -
packages/ui/src/locales/ro-RO/index.json | 9 -
packages/ui/src/locales/ru-RU/index.json | 9 -
packages/ui/src/locales/sr-CS/index.json | 10 -
packages/ui/src/locales/sv-SE/index.json | 9 -
packages/ui/src/locales/th-TH/index.json | 4 -
packages/ui/src/locales/tr-TR/index.json | 9 -
packages/ui/src/locales/uk-UA/index.json | 9 -
packages/ui/src/locales/vi-VN/index.json | 10 -
packages/ui/src/locales/zh-CN/index.json | 9 -
packages/ui/src/locales/zh-TW/index.json | 9 -
.../ui/src/stories/base/Toggle.stories.ts | 16 +-
packages/ui/src/utils/common-messages.ts | 2 +-
46 files changed, 1065 insertions(+), 505 deletions(-)
create mode 100644 packages/ui/src/layouts/shared/content-tab/components/ContentCardItemIcon.vue
create mode 100644 packages/ui/src/layouts/shared/content-tab/components/ContentEnabledFor.vue
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index c088dfb550d..d410b4fd08d 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -909,17 +909,18 @@ function getCardActions(
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
+ const showAsInstalling = isInstalling || (isInstallingSelection && isQueuedRoot)
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = showAsInstalled
? commonMessages.installedLabel
: isQueued
- ? isInstalling || isInstallingSelection
+ ? showAsInstalling
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.selectedLabel
- : isInstalling || isInstallingSelection
+ : showAsInstalling
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
@@ -928,16 +929,11 @@ function getCardActions(
{
key: 'install',
label: formatMessage(installLabel),
- icon:
- isInstalling || isInstallingSelection
- ? SpinnerIcon
- : isQueued || showAsInstalled
- ? CheckIcon
- : PlusIcon,
- iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
+ icon: showAsInstalling ? SpinnerIcon : isQueued || showAsInstalled ? CheckIcon : PlusIcon,
+ iconClass: showAsInstalling ? 'animate-spin' : undefined,
disabled:
showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
- color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
+ color: isQueued && !showAsInstalling ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
if (isQueuedRoot) {
diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue
index 09213818fdb..74877228d4c 100644
--- a/apps/frontend/src/pages/discover/[type]/index.vue
+++ b/apps/frontend/src/pages/discover/[type]/index.vue
@@ -361,17 +361,18 @@ function getCardActions(
serverData.value.upstream?.project_id === result.project_id
const isInstalling = installingProjectIds.value.has(result.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
+ const showAsInstalling = isInstalling || (isInstallingSelection && isQueuedRoot)
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
? formatMessage(commonMessages.installedLabel)
: isQueued
- ? isInstalling || isInstallingSelection
+ ? showAsInstalling
? validatingInstall
? formatMessage(commonMessages.validatingLabel)
: formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.selectedLabel)
- : isInstalling || isInstallingSelection
+ : showAsInstalling
? validatingInstall
? formatMessage(commonMessages.validatingLabel)
: formatMessage(commonMessages.installingLabel)
@@ -381,16 +382,11 @@ function getCardActions(
{
key: 'install',
label: installLabel,
- icon:
- isInstalling || isInstallingSelection
- ? SpinnerIcon
- : isQueued || isInstalled
- ? CheckIcon
- : DownloadIcon,
- iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
+ icon: showAsInstalling ? SpinnerIcon : isQueued || isInstalled ? CheckIcon : DownloadIcon,
+ iconClass: showAsInstalling ? 'animate-spin' : undefined,
disabled:
!!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
- color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
+ color: isQueued && !showAsInstalling ? 'green' : 'brand',
type: 'outlined',
onClick: () => serverInstall(projectResult),
},
diff --git a/packages/api-client/src/modules/archon/content/v1.ts b/packages/api-client/src/modules/archon/content/v1.ts
index fa0c951d6f2..71aedc9bd1f 100644
--- a/packages/api-client/src/modules/archon/content/v1.ts
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -105,6 +105,57 @@ export class ArchonContentV1Module extends AbstractModule {
})
}
+ /** POST /v1/:server_id/worlds/:world_id/addons/set-enabled-server */
+ public async setAddonEnabledServer(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.SetAddonEnabledRequest,
+ ): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/set-enabled-server`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/set-enabled-player */
+ public async setAddonEnabledPlayer(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.SetAddonEnabledRequest,
+ ): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/set-enabled-player`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ },
+ )
+ }
+
+ /** POST /v1/:server_id/worlds/:world_id/addons/set-locked-side-toggle */
+ public async setAddonSideToggleLocked(
+ serverId: string,
+ worldId: string,
+ request: Archon.Content.v1.SetAddonSideToggleLockedRequest,
+ ): Promise {
+ await this.client.request(
+ `/servers/${serverId}/worlds/${worldId}/addons/set-locked-side-toggle`,
+ {
+ api: 'archon',
+ version: 1,
+ method: 'POST',
+ body: request,
+ },
+ )
+ }
+
/** POST /v1/:server_id/worlds/:world_id/addons/delete-many */
public async deleteAddons(
serverId: string,
diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts
index e0a95cc5836..3c47e3bc844 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -301,6 +301,25 @@ export namespace Archon {
environment?: Labrinth.Projects.v3.Environment | null
}
+ export type AddonManifestEnvironment =
+ | 'client_and_server'
+ | 'client_only'
+ | 'dedicated_server_only'
+
+ export type AddonManifestWarnings = {
+ multiple_mod_entries: number | null
+ malformed: boolean
+ }
+
+ export type AddonManifest = {
+ platform: Modloader
+ name: string | null
+ version: string | null
+ environment: AddonManifestEnvironment | null
+ icon_embedded: boolean
+ warnings: AddonManifestWarnings
+ }
+
export type AddonStatus =
| 'pending'
| 'installed'
@@ -316,6 +335,10 @@ export namespace Archon {
filesize: number
btime?: string
disabled: boolean
+ disabled_server: boolean
+ disabled_player: boolean
+ side_toggle_unlocked: boolean
+ manifest: AddonManifest | null
kind: AddonKind
from_modpack: boolean
status: AddonStatus
@@ -354,6 +377,14 @@ export namespace Archon {
filename: string
}
+ export type SetAddonEnabledRequest = RemoveAddonRequest & {
+ enabled: boolean
+ }
+
+ export type SetAddonSideToggleLockedRequest = RemoveAddonRequest & {
+ locked: boolean
+ }
+
export type UpdateAddonRequest = {
filename: string
version_id?: string | null
@@ -1087,6 +1118,7 @@ export namespace Archon {
project_id: string | null
pack_client_retained: boolean
pack_client_depends: boolean
+ manifest?: Archon.Content.v1.AddonManifest | null
status: Archon.Content.v1.AddonStatus
filesize: number | null
name: string | null
diff --git a/packages/api-client/src/modules/kyros/content/v1.ts b/packages/api-client/src/modules/kyros/content/v1.ts
index 9f5f4f8b160..02d32927a88 100644
--- a/packages/api-client/src/modules/kyros/content/v1.ts
+++ b/packages/api-client/src/modules/kyros/content/v1.ts
@@ -7,6 +7,24 @@ export class KyrosContentV1Module extends AbstractModule {
return 'kyros_content_v1'
}
+ /** GET /v1/worlds/:world_id/content/embedded-icon */
+ public async getEmbeddedAddonIcon(
+ worldId: string,
+ parentDirectory: 'mods' | 'plugins',
+ filename: string,
+ ): Promise {
+ return this.client.request(`/worlds/${worldId}/content/embedded-icon`, {
+ api: '',
+ version: 'v1',
+ method: 'GET',
+ params: {
+ parent_directory: parentDirectory,
+ filename,
+ },
+ useNodeAuth: true,
+ })
+ }
+
/**
* Upload addon files to a world via multipart form data
*
diff --git a/packages/ui/src/components/base/Toggle.vue b/packages/ui/src/components/base/Toggle.vue
index 4ffa62d33ca..ddc0153102f 100644
--- a/packages/ui/src/components/base/Toggle.vue
+++ b/packages/ui/src/components/base/Toggle.vue
@@ -3,12 +3,12 @@
:id="id"
type="button"
role="switch"
- :aria-checked="modelValue"
+ :aria-checked="indeterminate ? 'mixed' : modelValue"
:disabled="disabled"
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border border-solid border-surface-5"
:class="[
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
- modelValue ? 'bg-brand' : 'bg-button-bg',
+ indeterminate || modelValue ? 'bg-brand' : 'bg-button-bg',
disabled ? 'opacity-50 cursor-not-allowed' : '',
]"
@click="toggle"
@@ -16,17 +16,24 @@
@@ -37,6 +44,7 @@ const props = defineProps<{
id?: string
disabled?: boolean
small?: boolean
+ indeterminate?: boolean
}>()
const modelValue = defineModel()
diff --git a/packages/ui/src/composables/server-panel-sync.ts b/packages/ui/src/composables/server-panel-sync.ts
index 30541a240ef..400cc150705 100644
--- a/packages/ui/src/composables/server-panel-sync.ts
+++ b/packages/ui/src/composables/server-panel-sync.ts
@@ -254,17 +254,48 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
}
const content = worldContentUpdateToAddons(event)
- queryClient.setQueryData(contentListKey(serverId), {
+ queryClient.setQueryData(contentListKey(serverId), (current) => ({
...content,
- addons: content.addons?.filter((addon) => !addon.from_modpack) ?? null,
- })
- queryClient.setQueryData(modpackContentListKey(serverId), {
- ...content,
- addons: content.addons?.filter((addon) => addon.from_modpack) ?? null,
- })
+ addons: mergeWorldContentSideState(
+ current?.addons ?? [],
+ content.addons?.filter((addon) => !addon.from_modpack) ?? [],
+ ),
+ }))
+ queryClient.setQueryData(
+ modpackContentListKey(serverId),
+ (current) => ({
+ ...content,
+ addons: mergeWorldContentSideState(
+ current?.addons ?? [],
+ content.addons?.filter((addon) => addon.from_modpack) ?? [],
+ ),
+ }),
+ )
+ void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
+ void queryClient.invalidateQueries({ queryKey: modpackContentListKey(serverId) })
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
}
+ function mergeWorldContentSideState(
+ currentAddons: Archon.Content.v1.Addon[],
+ incomingAddons: Archon.Content.v1.Addon[],
+ ) {
+ const currentByFilename = new Map(
+ currentAddons.map((addon) => [normalizeAddonFilename(addon.filename), addon] as const),
+ )
+ return incomingAddons.map((incoming) => {
+ const current = currentByFilename.get(normalizeAddonFilename(incoming.filename))
+ return current
+ ? {
+ ...incoming,
+ disabled_server: current.disabled_server,
+ disabled_player: current.disabled_player,
+ side_toggle_unlocked: current.side_toggle_unlocked,
+ }
+ : incoming
+ })
+ }
+
function worldContentUpdateToAddons(
event: Archon.Sync.v1.WorldContentUpdateEvent,
): Archon.Content.v1.Addons {
@@ -318,7 +349,11 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
filename: item.filename,
filesize: item.filesize ?? 0,
btime: item.btime,
- disabled: item.filename.endsWith('.disabled'),
+ disabled: false,
+ disabled_server: false,
+ disabled_player: false,
+ side_toggle_unlocked: false,
+ manifest: item.manifest ?? null,
kind: parentDirectoryToAddonKind(item.parent_directory),
from_modpack: item.from_modpack,
status: item.status,
diff --git a/packages/ui/src/composables/virtual-scroll.ts b/packages/ui/src/composables/virtual-scroll.ts
index 9bfbfc1456c..54bc6d54bb3 100644
--- a/packages/ui/src/composables/virtual-scroll.ts
+++ b/packages/ui/src/composables/virtual-scroll.ts
@@ -7,7 +7,7 @@ export interface ScrollViewportOptions {
}
export interface VirtualScrollOptions {
- itemHeight: number
+ itemHeight: number | Ref
bufferSize?: number
initialItemCount?: number
enabled?: Ref
@@ -140,6 +140,9 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
onNearEnd,
nearEndThreshold = 0.2,
} = options
+ const resolvedItemHeight = computed(() =>
+ typeof itemHeight === 'number' ? itemHeight : itemHeight.value,
+ )
const {
containerOffset,
@@ -153,7 +156,7 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
onScroll: checkNearEnd,
})
- const totalHeight = computed(() => items.value.length * itemHeight)
+ const totalHeight = computed(() => items.value.length * resolvedItemHeight.value)
const visibleRange = computed(() => {
if (enabled && !enabled.value) {
@@ -164,8 +167,8 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
}
- const start = Math.floor(relativeScrollTop.value / itemHeight)
- const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
+ const start = Math.floor(relativeScrollTop.value / resolvedItemHeight.value)
+ const visibleCount = Math.ceil(viewportHeight.value / resolvedItemHeight.value)
const rangeSize = visibleCount + bufferSize * 2
const rangeStart = Math.min(
@@ -181,7 +184,7 @@ export function useVirtualScroll(items: Ref, options: VirtualScrollOptio
})
const visibleTop = computed(() =>
- enabled && !enabled.value ? 0 : visibleRange.value.start * itemHeight,
+ enabled && !enabled.value ? 0 : visibleRange.value.start * resolvedItemHeight.value,
)
const visibleItems = computed(() =>
diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
index feefeefd210..bb71a6e8cba 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
@@ -29,11 +29,16 @@ import { truncatedTooltip } from '#ui/utils/truncate'
import type {
ClientWarningType,
+ ContentCardEmbeddedIcon,
ContentCardProject,
ContentCardVersion,
+ ContentEnabledForState,
ContentOwner,
+ ContentSide,
ContentSource,
} from '../types'
+import ContentCardItemIcon from './ContentCardItemIcon.vue'
+import ContentEnabledFor from './ContentEnabledFor.vue'
const { formatMessage } = useVIntl()
@@ -90,6 +95,8 @@ interface Props {
hideDelete?: boolean
hideActions?: boolean
inline?: boolean
+ enabledFor?: ContentEnabledForState
+ embeddedIcon?: ContentCardEmbeddedIcon
}
const props = withDefaults(defineProps(), {
@@ -120,6 +127,8 @@ const props = withDefaults(defineProps(), {
hideDelete: false,
hideActions: false,
inline: false,
+ enabledFor: undefined,
+ embeddedIcon: undefined,
})
const selected = defineModel('selected')
@@ -128,6 +137,7 @@ const projectTitle = computed(() => props.project.title.replace(/ยง[0-9a-fk-orx]
const emit = defineEmits<{
'update:enabled': [value: boolean]
+ 'update:enabled-for': [side: ContentSide, value: boolean]
select: [value: boolean, event?: MouseEvent]
delete: [event: MouseEvent]
update: []
@@ -149,6 +159,11 @@ const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled
const syncStatusLabel = computed(() =>
formatMessage(props.syncUpdatePending ? messages.syncUpdatePending : messages.synced),
)
+const toggleTooltip = computed(() => {
+ if (!isToggleDisabled.value) return undefined
+ return props.toggleDisabledTooltip ?? props.disabledTooltip ?? undefined
+})
+const isEnabledForDisabled = computed(() => !props.enabledFor?.server && !props.enabledFor?.player)
const clientWarningMessage = computed(() => {
switch (props.clientWarning) {
@@ -173,12 +188,15 @@ const installTooltip = computed(() => {
{
:class="
hideActions || !showVersion
? 'flex-1'
- : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
+ : enabledFor
+ ? 'flex-1 @[800px]:w-[340px] @[800px]:shrink-0 @[800px]:flex-none'
+ : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
"
>
{
+
+ emit('update:enabled-for', side, value)"
+ />
+
+
@@ -368,7 +396,8 @@ const installTooltip = computed(() => {
{
+import { useQuery } from '@tanstack/vue-query'
+import { computed, onScopeDispose, ref, watch } from 'vue'
+
+import Avatar from '#ui/components/base/Avatar.vue'
+
+import type { ContentCardEmbeddedIcon } from '../types'
+
+const props = defineProps<{
+ src?: string | null
+ fallbackUrl?: string | null
+ embeddedIcon?: ContentCardEmbeddedIcon
+ alt: string
+ tintBy?: string
+}>()
+
+const queryKey = computed(
+ () => props.embeddedIcon?.queryKey ?? (['content', 'embedded-icon', 'disabled'] as const),
+)
+
+const embeddedIconQuery = useQuery({
+ queryKey,
+ queryFn: () => {
+ if (!props.embeddedIcon) throw new Error('Missing embedded icon request')
+ return props.embeddedIcon.queryFn()
+ },
+ enabled: computed(
+ () => typeof window !== 'undefined' && !props.src && props.embeddedIcon !== undefined,
+ ),
+ staleTime: Infinity,
+})
+
+const embeddedIconUrl = ref()
+let pendingIconUrl: string | undefined
+let validationId = 0
+
+watch(
+ () => embeddedIconQuery.data.value,
+ (blob) => {
+ validationId += 1
+ const currentValidationId = validationId
+ if (embeddedIconUrl.value) URL.revokeObjectURL(embeddedIconUrl.value)
+ if (pendingIconUrl) URL.revokeObjectURL(pendingIconUrl)
+ embeddedIconUrl.value = undefined
+ pendingIconUrl = undefined
+ if (!blob) return
+
+ const candidateUrl = URL.createObjectURL(blob)
+ pendingIconUrl = candidateUrl
+ const image = new Image()
+ image.onload = () => {
+ if (currentValidationId !== validationId) return
+ pendingIconUrl = undefined
+ embeddedIconUrl.value = candidateUrl
+ }
+ image.onerror = () => {
+ if (currentValidationId !== validationId) return
+ pendingIconUrl = undefined
+ URL.revokeObjectURL(candidateUrl)
+ }
+ image.src = candidateUrl
+ },
+ { immediate: true },
+)
+
+onScopeDispose(() => {
+ validationId += 1
+ if (embeddedIconUrl.value) URL.revokeObjectURL(embeddedIconUrl.value)
+ if (pendingIconUrl) URL.revokeObjectURL(pendingIconUrl)
+})
+
+const resolvedSrc = computed(
+ () =>
+ props.src ??
+ embeddedIconUrl.value ??
+ props.embeddedIcon?.fallbackUrl ??
+ props.fallbackUrl ??
+ undefined,
+)
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardTable.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardTable.vue
index 02a606d4dc2..0ae7d96f63c 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardTable.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardTable.vue
@@ -1,9 +1,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/content-tab/components/managed-content-modal/index.vue b/packages/ui/src/layouts/shared/content-tab/components/managed-content-modal/index.vue
index 91a322254fa..12617e0dccd 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/managed-content-modal/index.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/managed-content-modal/index.vue
@@ -8,6 +8,7 @@ import {
PaintbrushIcon,
SearchIcon,
SpinnerIcon,
+ UnknownIcon,
} from '@modrinth/assets'
import Fuse from 'fuse.js'
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
@@ -30,7 +31,12 @@ import {
} from '#ui/utils/common-messages'
import { getClientWarningType } from '../../composables/content-filtering'
-import type { ContentCardProject, ContentCardTableItem, ContentItem } from '../../types'
+import type {
+ ContentCardProject,
+ ContentCardTableItem,
+ ContentItem,
+ ContentSide,
+} from '../../types'
import ContentCardTable from '../ContentCardTable.vue'
import ContentSelectionBar from '../ContentSelectionBar.vue'
@@ -44,6 +50,7 @@ interface Props {
sourceName?: string
sourceIconUrl?: string
enableToggle?: boolean
+ enableEnabledFor?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => ButtonMenuOption[]
@@ -60,6 +67,7 @@ const props = withDefaults(defineProps(), {
sourceName: undefined,
sourceIconUrl: undefined,
enableToggle: false,
+ enableEnabledFor: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
@@ -71,6 +79,7 @@ const props = withDefaults(defineProps(), {
const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
+ 'update:enabled-for': [item: ContentItem, side: ContentSide, value: boolean]
'bulk:enable': [items: ContentItem[]]
'bulk:disable': [items: ContentItem[]]
hide: []
@@ -129,6 +138,18 @@ const messages = defineMessages({
id: 'instances.managed-content-modal.disabled',
defaultMessage: 'Disabled',
},
+ enabledFor: {
+ id: 'content.enabled-for.label',
+ defaultMessage: 'Enabled for',
+ },
+ enabledForDescription: {
+ id: 'content.enabled-for.description',
+ defaultMessage: 'Choose where this content is enabled. Turn both off to disable it.',
+ },
+ pleaseWait: {
+ id: 'content.enabled-for.please-wait',
+ defaultMessage: 'Please wait',
+ },
})
export interface ManagedContentModalState {
@@ -285,6 +306,7 @@ const tableItems = computed(() =>
icon_url: item.embedded_metadata?.icon_url ?? null,
},
projectLink: !item.external && item.project?.id ? `/project/${item.project.id}` : undefined,
+ embeddedIcon: item.embeddedIcon,
version: props.showVersion
? (item.version ?? {
id: item.id,
@@ -307,18 +329,26 @@ const tableItems = computed(() =>
link: item.source.link ?? sourceProjectLink(item.source.project),
}
: undefined,
- ...(props.enableToggle ? { enabled: item.enabled } : {}),
+ ...(props.enableToggle || props.enableEnabledFor ? { enabled: item.enabled } : {}),
+ ...(props.enableEnabledFor ? { enabledFor: item.enabledFor } : {}),
synced: !!item.synced_pack,
syncUpdatePending: item.synced_pack?.update_pending,
locked: item.locked,
installing: item.installing === true,
toggleDisabled: props.actionDisabled,
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
- isClientOnly: getClientWarningType(item, props.showEnvironmentWarnings) !== null,
- clientWarning: getClientWarningType(item, props.showEnvironmentWarnings),
+ isClientOnly:
+ !props.enableEnabledFor && getClientWarningType(item, props.showEnvironmentWarnings) !== null,
+ clientWarning: props.enableEnabledFor
+ ? null
+ : getClientWarningType(item, props.showEnvironmentWarnings),
disabled:
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
- disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
+ disabledTooltip: props.actionDisabled
+ ? props.actionDisabledTooltip
+ : disabledIds.value.has(item.file_name)
+ ? formatMessage(messages.pleaseWait)
+ : undefined,
overflowOptions: [
...(props.switchVersion && !item.locked && item.project?.id && item.version?.id
? [
@@ -356,7 +386,9 @@ const externalUrls = computed(() => {
return urls
})
const hasExternalSlicerUrls = computed(() => Object.keys(externalSlicerUrls.value).length > 0)
-const showTableActions = computed(() => props.enableToggle || hasExternalSlicerUrls.value)
+const showTableActions = computed(
+ () => props.enableToggle || props.enableEnabledFor || hasExternalSlicerUrls.value,
+)
function getTypeIcon(type: string) {
switch (type) {
@@ -399,6 +431,13 @@ function handleEnabledChange(id: string, value: boolean) {
emit('update:enabled', item, value)
}
+function handleEnabledForChange(id: string, side: ContentSide, value: boolean) {
+ if (props.actionDisabled || !props.enableEnabledFor) return
+ const item = items.value.find((item) => item.id === id)
+ if (!item) return
+ emit('update:enabled-for', item, side, value)
+}
+
function bulkEnable() {
if (props.actionDisabled) return
emit('bulk:enable', [...toggleableSelectedItems.value])
@@ -504,8 +543,12 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
@@ -579,13 +622,16 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
@@ -601,16 +647,39 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
formatMessage(commonMessages.projectLabel)
}}
+
+ {{
+ formatMessage(messages.enabledFor)
+ }}
+
+
{{
formatMessage(commonMessages.versionLabel)
}}
-
+
{{
formatMessage(commonMessages.actionsLabel)
}}
@@ -624,14 +693,16 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
:show-selection="props.enableToggle"
:show-item-actions="showTableActions"
:show-version="showVersion"
+ :show-enabled-for-column="props.enableEnabledFor"
hide-delete
hide-header
flat
v-on="
- props.enableToggle
+ props.enableToggle || props.enableEnabledFor
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
: {}
"
+ @update:enabled-for="handleEnabledForChange"
>
(`content-sort:${ctx.filterPersistKey}`, 'alphabetical-asc')
- : ref('alphabetical-asc')
+ ? useSessionStorage(`content-sort:${ctx.filterPersistKey}`, defaultSortMode)
+ : ref(defaultSortMode)
+
+const validSortModes: readonly string[] = [
+ 'alphabetical-asc',
+ 'alphabetical-desc',
+ 'date-added-newest',
+ 'date-added-oldest',
+]
+if (!validSortModes.includes(sortMode.value)) sortMode.value = defaultSortMode
const sortLabels: Record string> = {
'alphabetical-asc': () => formatMessage(messages.sortAlphabeticalAscending),
@@ -192,32 +201,36 @@ const sortLabels: Record string> = {
'date-added-oldest': () => formatMessage(messages.sortDateAddedOldest),
}
-const sortOptions = computed(() => [
- {
- id: 'alphabetical-asc',
- label: formatMessage(messages.sortAlphabeticalAscending),
- icon: ArrowDownAZIcon,
- action: () => (sortMode.value = 'alphabetical-asc'),
- },
- {
- id: 'alphabetical-desc',
- label: formatMessage(messages.sortAlphabeticalDescending),
- icon: ArrowUpZAIcon,
- action: () => (sortMode.value = 'alphabetical-desc'),
- },
- {
- id: 'date-added-newest',
- label: formatMessage(messages.sortDateAddedNewest),
- icon: ClockArrowDownIcon,
- action: () => (sortMode.value = 'date-added-newest'),
- },
- {
- id: 'date-added-oldest',
- label: formatMessage(messages.sortDateAddedOldest),
- icon: ClockArrowUpIcon,
- action: () => (sortMode.value = 'date-added-oldest'),
- },
-])
+const sortOptions = computed(() => {
+ const options: ButtonMenuOption[] = [
+ {
+ id: 'alphabetical-asc',
+ label: formatMessage(messages.sortAlphabeticalAscending),
+ icon: ArrowDownAZIcon,
+ action: () => (sortMode.value = 'alphabetical-asc'),
+ },
+ {
+ id: 'alphabetical-desc',
+ label: formatMessage(messages.sortAlphabeticalDescending),
+ icon: ArrowUpZAIcon,
+ action: () => (sortMode.value = 'alphabetical-desc'),
+ },
+ {
+ id: 'date-added-newest',
+ label: formatMessage(messages.sortDateAddedNewest),
+ icon: ClockArrowDownIcon,
+ action: () => (sortMode.value = 'date-added-newest'),
+ },
+ {
+ id: 'date-added-oldest',
+ label: formatMessage(messages.sortDateAddedOldest),
+ icon: ClockArrowUpIcon,
+ action: () => (sortMode.value = 'date-added-oldest'),
+ },
+ ]
+
+ return options
+})
const sortedItems = computed(() => {
const items = [...ctx.items.value]
@@ -436,14 +449,22 @@ const tableItems = computed(() => {
const base = ctx.mapToTableItem(item)
const id = getItemId(item)
const locked = base.locked ?? item.locked ?? false
- const clientWarning = getClientWarningType(item, ctx.showEnvironmentWarnings)
+ const clientWarning = base.enabledFor
+ ? null
+ : getClientWarningType(item, ctx.showEnvironmentWarnings)
return {
...base,
id,
locked,
disabled:
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
- disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
+ disabledTooltip: ctx.isBusy.value
+ ? (ctx.busyMessage?.value ?? null)
+ : isChanging(id)
+ ? formatMessage(messages.pleaseWait)
+ : item.installing
+ ? formatMessage(commonMessages.installingLabel)
+ : null,
toggleDisabled: ctx.isBusy.value || base.toggleDisabled,
toggleDisabledTooltip: ctx.isBusy.value
? (ctx.busyMessage?.value ?? null)
@@ -517,7 +538,10 @@ function canDeleteItem(item: ContentItem) {
}
function canToggleItem(item: ContentItem) {
- return ctx.canToggleItem?.(item) ?? true
+ return (
+ ctx.canToggleItem?.(item) ??
+ !!(ctx.toggleEnabled || ctx.bulkEnableItems || ctx.bulkDisableItems)
+ )
}
const deletableSelectedItems = computed(() => selectedItems.value.filter(canDeleteItem))
@@ -640,6 +664,7 @@ async function disableItemsWithoutWarning(items: ContentItem[]) {
await ctx.bulkDisableItems(items)
return
}
+ if (!ctx.toggleEnabled) return
for (const item of items) {
const id = getItemId(item)
@@ -754,7 +779,7 @@ async function confirmDisable() {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(itemsToDisable)
} else {
- await ctx.toggleEnabled(item)
+ await ctx.toggleEnabled?.(item)
}
} finally {
unmarkChanging(id)
@@ -768,7 +793,7 @@ async function confirmDisable() {
}
async function handleToggleEnabledById(id: string, _value: boolean) {
- if (ctx.isBusy.value) return
+ if (ctx.isBusy.value || !ctx.toggleEnabled) return
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
if (!canToggleItem(item)) return
@@ -808,7 +833,8 @@ async function bulkEnable() {
}
return
}
- await runBulk('enable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
+ if (!ctx.toggleEnabled) return
+ await runBulk('enable', items, (item) => ctx.toggleEnabled!(item), { onComplete: clearSelection })
}
async function bulkDisable() {
@@ -831,6 +857,14 @@ function handleSwitchVersionById(id: string) {
}
}
+async function handleSetEnabledForById(id: string, side: 'server' | 'player', enabled: boolean) {
+ if (ctx.isBusy.value || !ctx.setEnabledFor) return
+ const item = ctx.items.value.find((candidate) => getItemId(candidate) === id)
+ if (!item) return
+
+ await ctx.setEnabledFor(item, side, enabled)
+}
+
// Bulk updating
const confirmBulkUpdateModal = ref>()
const pendingBulkUpdateItems = ref([])
@@ -1247,7 +1281,9 @@ const confirmUnlinkModal = ref>()
:items="tableItems"
:highlighted-item-id="highlightedItemId"
:show-selection="true"
+ :show-enabled-for-column="!!ctx.setEnabledFor"
@update:enabled="handleToggleEnabledById"
+ @update:enabled-for="handleSetEnabledForById"
@delete="handleDeleteById"
@update="handleUpdateById"
@switch-version="handleSwitchVersionById"
diff --git a/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts b/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts
index 71b052cde75..2f9f9d5485d 100644
--- a/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts
+++ b/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts
@@ -8,6 +8,7 @@ import type {
ContentActionWarning,
ContentCardTableItem,
ContentItem,
+ ContentSide,
ManagedContentCardData,
} from '../types'
@@ -46,7 +47,8 @@ export interface ContentManagerContext {
contentTypeLabel: Ref | ComputedRef
// Core actions
- toggleEnabled: (item: ContentItem) => Promise
+ toggleEnabled?: (item: ContentItem) => Promise
+ setEnabledFor?: (item: ContentItem, side: ContentSide, enabled: boolean) => Promise
deleteItem: (item: ContentItem) => Promise
refresh: () => Promise
browse: () => void
diff --git a/packages/ui/src/layouts/shared/content-tab/types.ts b/packages/ui/src/layouts/shared/content-tab/types.ts
index f5cb9e76a50..16e1ea6b704 100644
--- a/packages/ui/src/layouts/shared/content-tab/types.ts
+++ b/packages/ui/src/layouts/shared/content-tab/types.ts
@@ -50,6 +50,22 @@ export interface EmbeddedContentMetadata {
icon_url?: string | null
}
+export type ContentSide = 'server' | 'player'
+
+export interface ContentEnabledForState {
+ server: boolean
+ player: boolean
+ locked: boolean
+ disabledSides?: ContentSide[]
+ warningTooltip?: string | null
+}
+
+export interface ContentCardEmbeddedIcon {
+ queryKey: readonly unknown[]
+ queryFn: () => Promise
+ fallbackUrl?: string | null
+}
+
export interface ContentCardTableItem {
id: string
project: ContentCardProject
@@ -76,6 +92,8 @@ export interface ContentCardTableItem {
hideDelete?: boolean
hideSwitchVersion?: boolean
overflowOptions?: ButtonMenuOption[]
+ enabledFor?: ContentEnabledForState
+ embeddedIcon?: ContentCardEmbeddedIcon
}
export type ContentCardTableSortColumn = 'project' | 'version'
diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue
index 51e3c4ca3fd..72b316f0c7c 100644
--- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue
+++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue
@@ -500,6 +500,26 @@ async function uploadLocalModpackWithSoftOverride() {
return true
}
+async function disableAddonsEverywhere(addons: Archon.Content.v1.Addon[]) {
+ await Promise.all(
+ addons.flatMap((addon) => {
+ const request: Archon.Content.v1.SetAddonEnabledRequest = {
+ kind: addon.kind,
+ filename: addon.filename,
+ enabled: false,
+ }
+ return [
+ ...(!addon.disabled_server
+ ? [client.archon.content_v1.setAddonEnabledServer(serverId, worldId.value!, request)]
+ : []),
+ ...(!addon.disabled_player
+ ? [client.archon.content_v1.setAddonEnabledPlayer(serverId, worldId.value!, request)]
+ : []),
+ ]
+ }),
+ )
+}
+
provideInstallationSettings({
closeSettings: serverSettings.closeModal,
afterSave: async () => {
@@ -899,12 +919,12 @@ provideInstallationSettings({
if (setupActionDisabled.value) return
debug('disableAllContent: fetching all addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
- const items = (addons.addons ?? [])
- .filter((a) => !a.disabled)
- .map((a) => ({ kind: a.kind, filename: a.filename }))
- if (items.length > 0) {
- debug('disableAllContent: disabling', items.length, 'addons')
- await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
+ const activeAddons = (addons.addons ?? []).filter(
+ (addon) => !addon.disabled_server || !addon.disabled_player,
+ )
+ if (activeAddons.length > 0) {
+ debug('disableAllContent: disabling', activeAddons.length, 'addons')
+ await disableAddonsEverywhere(activeAddons)
}
debug('disableAllContent: done')
},
@@ -913,7 +933,9 @@ provideInstallationSettings({
if (setupActionDisabled.value) return
debug('disableIncompatibleContent: fetching addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
- const activeAddons = (addons.addons ?? []).filter((a) => !a.disabled)
+ const activeAddons = (addons.addons ?? []).filter(
+ (addon) => !addon.disabled_server || !addon.disabled_player,
+ )
const modrinthAddons = activeAddons.filter((a) => a.version?.id)
const customAddons = activeAddons.filter((a) => !a.version?.id)
@@ -936,7 +958,12 @@ provideInstallationSettings({
if (incompatibleItems.length > 0) {
debug('disableIncompatibleContent: disabling', incompatibleItems.length, 'addons')
- await client.archon.content_v1.disableAddons(serverId, worldId.value!, incompatibleItems)
+ const incompatibleKeys = new Set(
+ incompatibleItems.map((item) => `${item.kind}:${item.filename}`),
+ )
+ await disableAddonsEverywhere(
+ activeAddons.filter((addon) => incompatibleKeys.has(`${addon.kind}:${addon.filename}`)),
+ )
}
debug('disableIncompatibleContent: done')
},
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
index c32ba61b1e5..47e52c3dad4 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
@@ -1,10 +1,11 @@
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts
index e7300aec278..1165ef04cca 100644
--- a/apps/frontend/src/composables/featureFlags.ts
+++ b/apps/frontend/src/composables/featureFlags.ts
@@ -59,7 +59,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
dismissedPhotosensitivityFilterWarning: false,
- ServerPlayFrontend: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue
index 9e218386abf..5f6d5245f42 100644
--- a/apps/frontend/src/pages/hosting/manage/[id].vue
+++ b/apps/frontend/src/pages/hosting/manage/[id].vue
@@ -5,7 +5,6 @@
:resolve-viewer="resolveViewer"
:show-copy-id-action="flags.developerMode"
:show-advanced-debug-info="flags.advancedDebugInfo"
- :show-play-tab="flags.ServerPlayFrontend"
:stripe-publishable-key="config.public.stripePublishableKey as string"
:site-url="config.public.siteUrl as string"
:products="products"
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/play.vue b/apps/frontend/src/pages/hosting/manage/[id]/play.vue
index b4866d98f76..0c23d78a03e 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/play.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/play.vue
@@ -2,12 +2,6 @@
import { injectModrinthServerContext, ServersManagePlayPage } from '@modrinth/ui'
const { server } = injectModrinthServerContext()
-const flags = useFeatureFlags()
-const route = useNativeRoute()
-
-if (!flags.value.ServerPlayFrontend) {
- await navigateTo(`/hosting/manage/${String(route.params.id)}`, { replace: true })
-}
useHead({
title: computed(() => `Play - ${server.value?.name ?? 'Server'} - Modrinth`),
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
index 84bc3b13ba1..fdf9a2b5f42 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -395,7 +395,6 @@ const props = withDefaults(
showCopyIdAction?: boolean
showAdvancedDebugInfo?: boolean
showUptime?: boolean
- showPlayTab?: boolean
additionalTabs?: Tab[]
stripePublishableKey?: string
siteUrl?: string
@@ -420,7 +419,6 @@ const props = withDefaults(
showCopyIdAction: false,
showAdvancedDebugInfo: false,
showUptime: true,
- showPlayTab: false,
additionalTabs: () => [],
stripePublishableKey: undefined,
siteUrl: undefined,
@@ -778,16 +776,12 @@ watch(serverData, (data) => {
})
const navLinks = computed(() => [
- ...(props.showPlayTab
- ? [
- {
- label: 'Play',
- href: `/hosting/manage/${props.serverId}/play`,
- icon: PlayIcon,
- subpages: [],
- },
- ]
- : []),
+ {
+ label: 'Play',
+ href: `/hosting/manage/${props.serverId}/play`,
+ icon: PlayIcon,
+ subpages: [],
+ },
{
label: 'Overview',
href: `/hosting/manage/${props.serverId}`,
From 3c61c0edf6e9e606de65c28ba436d9d500df2db5 Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Fri, 4 Sep 2026 16:03:22 +0100
Subject: [PATCH 05/13] fix: build
---
.../wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
index 56730841e2d..1426bdb6113 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
@@ -1,12 +1,12 @@
-
Date: Fri, 4 Sep 2026 16:55:24 +0100
Subject: [PATCH 06/13] dev: notes
---
SERVER_PLAY_NOTES.md | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 SERVER_PLAY_NOTES.md
diff --git a/SERVER_PLAY_NOTES.md b/SERVER_PLAY_NOTES.md
new file mode 100644
index 00000000000..80a156db144
--- /dev/null
+++ b/SERVER_PLAY_NOTES.md
@@ -0,0 +1,10 @@
+Flow:
+1. Shared instance wont exist until they:
+ - Click "Play server"
+ - Need special handling in-app
+ - Click "Invite players"
+ - Click "Download mrpack"
+ - disable btns until share response is done, then continue
+2. When content changes, watch shared_instance_needs_update: boolean on WorldsFull, show "Push update" admonition like app instances
+ 1. Push update flow like usual, show content changes diff]
+ 2. tbd
From 4e0ea14c877cd5448ecb38446010cd0c175b3932 Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Thu, 10 Sep 2026 14:10:28 +0100
Subject: [PATCH 07/13] feat: impl endpoints into panel
---
SERVER_PLAY_NOTES.md | 10 -
apps/app-frontend/src/App.vue | 13 +
.../ui/hosting/HostingPlayHandler.vue | 185 ++++++++
.../generated/app-events/CommandPayload.ts | 2 +-
.../generated/app-events/postcard/index.d.ts | 2 +-
.../generated/app-events/postcard/index.js | 8 +
.../src/pages/hosting/manage/Play.vue | 14 +-
apps/app-frontend/src/routes.js | 2 +-
apps/app/tauri.conf.json | 2 +-
.../src/pages/hosting/manage/[id]/play.vue | 23 +-
.../src/modules/archon/content/v1.ts | 12 +
.../api-client/src/modules/archon/types.ts | 34 ++
.../modules/shared-instances/instances/v1.ts | 18 +
.../modules/shared-instances/invites/v1.ts | 18 +
.../src/modules/shared-instances/types.ts | 7 +
packages/api-client/src/platform/generic.ts | 5 +-
packages/api-client/src/platform/nuxt.ts | 1 +
packages/api-client/src/platform/tauri.ts | 2 +-
packages/api-client/src/types/request.ts | 3 +
packages/app-lib/src/api/handler.rs | 15 +
packages/app-lib/src/event/mod.rs | 4 +
.../ui/src/composables/server-panel-sync.ts | 11 +
.../shared/server-settings/pages/general.vue | 8 +
.../manage/[id]/play/ServerPlayCard.vue | 60 +--
.../manage/[id]/play/ServerPlayersTable.vue | 4 +-
.../wrapped/hosting/manage/[id]/play/play.vue | 415 +++++++++---------
.../hosting/manage/[id]/play/share-diff.ts | 35 ++
.../manage/[id]/play/use-server-players.ts | 116 +++++
packages/ui/src/providers/index.ts | 1 +
packages/ui/src/providers/server-play.ts | 20 +
30 files changed, 774 insertions(+), 276 deletions(-)
delete mode 100644 SERVER_PLAY_NOTES.md
create mode 100644 apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/share-diff.ts
create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/use-server-players.ts
create mode 100644 packages/ui/src/providers/server-play.ts
diff --git a/SERVER_PLAY_NOTES.md b/SERVER_PLAY_NOTES.md
deleted file mode 100644
index 80a156db144..00000000000
--- a/SERVER_PLAY_NOTES.md
+++ /dev/null
@@ -1,10 +0,0 @@
-Flow:
-1. Shared instance wont exist until they:
- - Click "Play server"
- - Need special handling in-app
- - Click "Invite players"
- - Click "Download mrpack"
- - disable btns until share response is done, then continue
-2. When content changes, watch shared_instance_needs_update: boolean on WorldsFull, show "Push update" admonition like app instances
- 1. Push update flow like usual, show content changes diff]
- 2. tbd
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 5faea7c84a0..2add6f3bddc 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -96,6 +96,9 @@ import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
+import HostingPlayHandler from '@/components/ui/hosting/HostingPlayHandler.vue'
+import { provideServerPlay } from '@modrinth/ui'
+
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
@@ -1133,6 +1136,13 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
+const hostingPlayHandler = ref()
+provideServerPlay({
+ async play(target) {
+ if (!hostingPlayHandler.value) throw new Error('Server play handler is not ready.')
+ await hostingPlayHandler.value.play(target)
+ },
+})
const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
@@ -1767,6 +1777,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
+ } else if (e.event === 'PlayHostingServer') {
+ await hostingPlayHandler.value?.play({ serverId: e.server_id, worldId: e.world_id })
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
@@ -2616,6 +2628,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
+
diff --git a/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue b/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
new file mode 100644
index 00000000000..b952435e5e0
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/generated/app-events/CommandPayload.ts b/apps/app-frontend/src/generated/app-events/CommandPayload.ts
index 1183c97876c..d844877140a 100644
--- a/apps/app-frontend/src/generated/app-events/CommandPayload.ts
+++ b/apps/app-frontend/src/generated/app-events/CommandPayload.ts
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
-export type CommandPayload = { "event": "InstallMod", id: string, } | { "event": "InstallVersion", id: string, } | { "event": "InstallModpack", id: string, } | { "event": "InstallServer", id: string, } | { "event": "LaunchInstance", id: string, server: string | null, singleplayer_world: string | null, } | { "event": "InstallSharedInstanceInvite", invite_id: string, } | { "event": "RunMRPack", path: string, };
+export type CommandPayload = { "event": "InstallMod", id: string, } | { "event": "InstallVersion", id: string, } | { "event": "InstallModpack", id: string, } | { "event": "InstallServer", id: string, } | { "event": "LaunchInstance", id: string, server: string | null, singleplayer_world: string | null, } | { "event": "InstallSharedInstanceInvite", invite_id: string, } | { "event": "RunMRPack", path: string, } | { "event": "PlayHostingServer", server_id: string, world_id: string, };
diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts
index e763358993f..63f113e920c 100644
--- a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts
+++ b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts
@@ -40,7 +40,7 @@ export type LoadingPayload = { event: LoadingBarType, loader_uuid: string, fract
export type WarningPayload = { message: string }
export type InstanceBulkUpdateProgressPayload = { instanceId: string, stage: InstanceBulkUpdateProgressStage, current: u64, total: u64 }
export type InstanceBulkUpdateProgressStage = { tag: "resolving_versions" } | { tag: "downloading" } | { tag: "finishing" }
-export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { tag: "InstallVersion", value: { id: string } } | { tag: "InstallModpack", value: { id: string } } | { tag: "InstallServer", value: { id: string } } | { tag: "LaunchInstance", value: { id: string, server: string | undefined, singleplayer_world: string | undefined } } | { tag: "InstallSharedInstanceInvite", value: { invite_id: string } } | { tag: "RunMRPack", value: { path: string } }
+export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { tag: "InstallVersion", value: { id: string } } | { tag: "InstallModpack", value: { id: string } } | { tag: "InstallServer", value: { id: string } } | { tag: "LaunchInstance", value: { id: string, server: string | undefined, singleplayer_world: string | undefined } } | { tag: "InstallSharedInstanceInvite", value: { invite_id: string } } | { tag: "RunMRPack", value: { path: string } } | { tag: "PlayHostingServer", value: { server_id: string, world_id: string } }
export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string }
export type ProcessPayloadType = { tag: "launched" } | { tag: "finished" }
export type InstancePayload = { instance_id: string, event: InstancePayloadType }
diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.js b/apps/app-frontend/src/generated/app-events/postcard/index.js
index 2407d7da9b5..d43f4a09165 100644
--- a/apps/app-frontend/src/generated/app-events/postcard/index.js
+++ b/apps/app-frontend/src/generated/app-events/postcard/index.js
@@ -300,6 +300,14 @@ function deserialize_COMMAND_PAYLOAD(d) {
path: d.deserialize_string()
}
};
+ case 7:
+ return {
+ tag: "PlayHostingServer",
+ value: {
+ server_id: d.deserialize_string(),
+ world_id: d.deserialize_string()
+ }
+ };
default:
throw "variant not implemented"
}
diff --git a/apps/app-frontend/src/pages/hosting/manage/Play.vue b/apps/app-frontend/src/pages/hosting/manage/Play.vue
index 170357365d3..e9147c188e3 100644
--- a/apps/app-frontend/src/pages/hosting/manage/Play.vue
+++ b/apps/app-frontend/src/pages/hosting/manage/Play.vue
@@ -1,7 +1,17 @@
-
+
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index 4ec2ce94fe0..12d5bd1c422 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -23,7 +23,7 @@ export default new createRouter({
{
path: 'play',
name: 'ServerManagePlay',
- component: Hosting.Play,
+ component: () => import('@/pages/hosting/manage/Play.vue'),
},
{
path: '',
diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json
index db4ef3624bf..9552e0c77c1 100644
--- a/apps/app/tauri.conf.json
+++ b/apps/app/tauri.conf.json
@@ -102,7 +102,7 @@
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
- "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
+ "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://kyros-test2.tail029726.ts.net wss://kyros-test2.tail029726.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
"font-src": ["https://cdn.modrinth.com/fonts/", "https://js.intercomcdn.com"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
diff --git a/apps/frontend/src/pages/hosting/manage/[id]/play.vue b/apps/frontend/src/pages/hosting/manage/[id]/play.vue
index 0c23d78a03e..630bc537b84 100644
--- a/apps/frontend/src/pages/hosting/manage/[id]/play.vue
+++ b/apps/frontend/src/pages/hosting/manage/[id]/play.vue
@@ -1,13 +1,26 @@
-
+
diff --git a/packages/api-client/src/modules/archon/content/v1.ts b/packages/api-client/src/modules/archon/content/v1.ts
index 71aedc9bd1f..1720d758b78 100644
--- a/packages/api-client/src/modules/archon/content/v1.ts
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -6,6 +6,18 @@ export class ArchonContentV1Module extends AbstractModule {
return 'archon_content_v1'
}
+ public async share(serverId: string, worldId: string): Promise {
+ return this.client.request(`/servers/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId)}/content/share`, {
+ api: 'archon', version: 1, method: 'POST', timeout: 600_000, retry: false,
+ })
+ }
+
+ public async getShareDiff(serverId: string, worldId: string): Promise {
+ return this.client.request(`/servers/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId)}/content/share/diff`, {
+ api: 'archon', version: 1, method: 'GET',
+ })
+ }
+
/** GET /v1/:server_id/worlds/:world_id/addons */
public async getAddons(
serverId: string,
diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts
index 3c47e3bc844..22c68532943 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -284,6 +284,30 @@ export namespace Archon {
export namespace Content {
export namespace v1 {
+ export type ShareWorldContentResponse = {
+ shared_instance_id: string
+ version: number | null
+ }
+
+ export type SharedContentChange =
+ | { kind: 'added'; after: T }
+ | { kind: 'removed'; before: T }
+ | { kind: 'updated'; before: T; after: T }
+
+ export type SharedContentDiffEntry =
+ | { type: 'project'; project_id: string; change: SharedContentChange }
+ | { type: 'external_file'; file_type: string; file_name: string; kind: 'added' | 'removed' | 'updated' }
+ | { type: 'modpack' | 'game_version'; change: SharedContentChange }
+ | { type: 'loader'; change: SharedContentChange<{ name: string; version: string | null }> }
+
+ export type SharedInstancePublishDiff = {
+ shared_instance_id: string
+ latest_version: number
+ local_updated_at: string
+ has_changes: boolean
+ diffs: SharedContentDiffEntry[]
+ }
+
export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
export type ContentOwnerType = 'user' | 'organization'
@@ -835,6 +859,8 @@ export namespace Archon {
}
export type WorldContentInfo = {
+ shared_instance_id: string | null
+ shared_instance_needs_update: boolean
modloader: string
modloader_version: string
game_version: string
@@ -1139,6 +1165,13 @@ export namespace Archon {
content: WorldContentItem[]
}
+ export type WorldSharedInstanceUpdateEvent = {
+ type: 'world.shared_instance.update'
+ world_id: string
+ shared_instance_id: string
+ needs_update: boolean
+ }
+
export type SyncEvent =
| ProtocolResetEvent
| ProtocolInvalidEvent
@@ -1158,6 +1191,7 @@ export namespace Archon {
| WorldContentAddonPatchEvent
| WorldContentBaseUpdateEvent
| WorldContentUpdateEvent
+ | WorldSharedInstanceUpdateEvent
}
}
diff --git a/packages/api-client/src/modules/shared-instances/instances/v1.ts b/packages/api-client/src/modules/shared-instances/instances/v1.ts
index 7b958104639..727568413c0 100644
--- a/packages/api-client/src/modules/shared-instances/instances/v1.ts
+++ b/packages/api-client/src/modules/shared-instances/instances/v1.ts
@@ -63,4 +63,22 @@ export class SharedInstancesInstancesV1Module extends AbstractModule {
},
)
}
+
+ public async inviteUsers(instanceId: string, userIds: string[]): Promise<{ failed: string[] }> {
+ return this.client.request(`/instances/${encodeURIComponent(instanceId)}/users`, {
+ api: 'sharedinstances', version: 1, method: 'POST', body: { user_ids: userIds }, retry: false,
+ })
+ }
+
+ public async removeUsers(instanceId: string, userIds: string[]): Promise {
+ return this.client.request(`/instances/${encodeURIComponent(instanceId)}/users`, {
+ api: 'sharedinstances', version: 1, method: 'DELETE', body: { user_ids: userIds },
+ })
+ }
+
+ public async downloadMrpack(instanceId: string, version: number): Promise {
+ return this.client.request(`/instances/${encodeURIComponent(instanceId)}/versions/${version}/mrpack`, {
+ api: 'sharedinstances', version: 1, method: 'GET', responseType: 'blob', timeout: 600_000,
+ })
+ }
}
diff --git a/packages/api-client/src/modules/shared-instances/invites/v1.ts b/packages/api-client/src/modules/shared-instances/invites/v1.ts
index 72536cd0a0e..8ff2f2f92ac 100644
--- a/packages/api-client/src/modules/shared-instances/invites/v1.ts
+++ b/packages/api-client/src/modules/shared-instances/invites/v1.ts
@@ -18,4 +18,22 @@ export class SharedInstancesInvitesV1Module extends AbstractModule {
},
)
}
+
+ public async list(instanceId: string): Promise {
+ return this.client.request(`/instances/${encodeURIComponent(instanceId)}/invites`, {
+ api: 'sharedinstances', version: 1, method: 'GET',
+ })
+ }
+
+ public async create(instanceId: string, options: { max_age?: number; max_uses: number }): Promise<{ id: string }> {
+ return this.client.request(`/instances/${encodeURIComponent(instanceId)}/invites`, {
+ api: 'sharedinstances', version: 1, method: 'POST', body: options, retry: false,
+ })
+ }
+
+ public async delete(instanceId: string, inviteId: string): Promise {
+ return this.client.request(`/instances/${encodeURIComponent(instanceId)}/invites/${encodeURIComponent(inviteId)}`, {
+ api: 'sharedinstances', version: 1, method: 'DELETE',
+ })
+ }
}
diff --git a/packages/api-client/src/modules/shared-instances/types.ts b/packages/api-client/src/modules/shared-instances/types.ts
index 4ad1e3afbf7..c41d30a55e9 100644
--- a/packages/api-client/src/modules/shared-instances/types.ts
+++ b/packages/api-client/src/modules/shared-instances/types.ts
@@ -23,6 +23,13 @@ export namespace SharedInstances {
joined_at: string | null
}
+ export type InviteLink = {
+ id: string
+ expiration: string
+ max_uses: number
+ uses: number
+ }
+
export type Invite = {
instance_id: string
instance_name: string
diff --git a/packages/api-client/src/platform/generic.ts b/packages/api-client/src/platform/generic.ts
index 7ec63b68249..bc2f1972286 100644
--- a/packages/api-client/src/platform/generic.ts
+++ b/packages/api-client/src/platform/generic.ts
@@ -46,8 +46,9 @@ export class GenericModrinthClient extends XHRUploadClient {
protected async executeRequest(url: string, options: RequestOptions): Promise {
try {
- const response = await $fetch(url, {
+ const response = await $fetch(url, {
method: options.method ?? 'GET',
+ responseType: options.responseType,
headers: options.headers,
body: options.body as BodyInit,
params: options.params as Record,
@@ -55,7 +56,7 @@ export class GenericModrinthClient extends XHRUploadClient {
signal: options.signal,
})
- return response
+ return response as T
} catch (error) {
// ofetch throws FetchError for HTTP errors
throw this.normalizeError(error)
diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts
index a5308535ba3..b09f5077fca 100644
--- a/packages/api-client/src/platform/nuxt.ts
+++ b/packages/api-client/src/platform/nuxt.ts
@@ -160,6 +160,7 @@ export class NuxtModrinthClient extends XHRUploadClient {
// @ts-expect-error - $fetch is provided by Nuxt
const response = await $fetch(url, {
method: options.method ?? 'GET',
+ responseType: options.responseType,
headers: options.headers,
body: options.body,
params: options.params,
diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts
index ac82b53edba..78fd17787c2 100644
--- a/packages/api-client/src/platform/tauri.ts
+++ b/packages/api-client/src/platform/tauri.ts
@@ -92,7 +92,7 @@ export class TauriModrinthClient extends XHRUploadClient {
// Handle binary downloads (e.g. kyros fs files) before JSON parsing.
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
- if (fullUrl.includes('/fs/download')) {
+ if (options.responseType === 'blob' || fullUrl.includes('/fs/download')) {
return (await response.blob()) as T
}
if (
diff --git a/packages/api-client/src/types/request.ts b/packages/api-client/src/types/request.ts
index d1a57d8845a..ca7b9836530 100644
--- a/packages/api-client/src/types/request.ts
+++ b/packages/api-client/src/types/request.ts
@@ -29,6 +29,9 @@ export type RequestOptions = {
*/
method?: HttpMethod
+ /** Explicit response decoding for binary downloads. */
+ responseType?: 'blob'
+
/**
* Request headers
*/
diff --git a/packages/app-lib/src/api/handler.rs b/packages/app-lib/src/api/handler.rs
index 1f16218dc8b..f53f465c719 100644
--- a/packages/app-lib/src/api/handler.rs
+++ b/packages/app-lib/src/api/handler.rs
@@ -30,6 +30,21 @@ pub async fn handle_url(sublink: &str) -> crate::Result {
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
+ Some(("hosting", path)) => {
+ let (server_id, world_id) = path.split_once('/').ok_or_else(|| {
+ crate::ErrorKind::InputError("Missing hosting world ID".to_string())
+ })?;
+ let server_id = uuid::Uuid::parse_str(server_id).map_err(|_| {
+ crate::ErrorKind::InputError("Invalid hosting server ID".to_string())
+ })?;
+ let world_id = uuid::Uuid::parse_str(world_id).map_err(|_| {
+ crate::ErrorKind::InputError("Invalid hosting world ID".to_string())
+ })?;
+ CommandPayload::PlayHostingServer {
+ server_id: server_id.to_string(),
+ world_id: world_id.to_string(),
+ }
+ }
// /share/{invite_id}
Some(("share", raw)) => {
let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs
index 833349d4228..d222a7b9aca 100644
--- a/packages/app-lib/src/event/mod.rs
+++ b/packages/app-lib/src/event/mod.rs
@@ -413,6 +413,10 @@ pub enum CommandPayload {
// run or install .mrpack
path: String,
},
+ PlayHostingServer {
+ server_id: String,
+ world_id: String,
+ },
}
#[derive(Serialize, Deserialize, Clone)]
diff --git a/packages/ui/src/composables/server-panel-sync.ts b/packages/ui/src/composables/server-panel-sync.ts
index 400cc150705..2758664ade2 100644
--- a/packages/ui/src/composables/server-panel-sync.ts
+++ b/packages/ui/src/composables/server-panel-sync.ts
@@ -118,6 +118,17 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
case 'world.content.base.update':
handleWorldContentBaseUpdate(serverId, event)
break
+ case 'world.shared_instance.update':
+ patchServerFullWorld(serverId, event.world_id, (world) => world.content ? {
+ ...world,
+ content: {
+ ...world.content,
+ shared_instance_id: event.shared_instance_id,
+ shared_instance_needs_update: event.needs_update,
+ },
+ } : world)
+ void queryClient.invalidateQueries({ queryKey: ['servers', 'share-diff', serverId, event.world_id] })
+ break
case 'world.content.update':
handleWorldContentUpdate(serverId, event)
break
diff --git a/packages/ui/src/layouts/shared/server-settings/pages/general.vue b/packages/ui/src/layouts/shared/server-settings/pages/general.vue
index e54c06e13fc..8e7c1369dd0 100644
--- a/packages/ui/src/layouts/shared/server-settings/pages/general.vue
+++ b/packages/ui/src/layouts/shared/server-settings/pages/general.vue
@@ -206,6 +206,11 @@ const preferences = {
description: 'Show RAM usage in bytes instead of a percentage.',
implemented: true,
},
+ reviewChangesBeforePlaying: {
+ displayName: 'Review changes before playing',
+ description: 'Show pending shared content changes before Play server publishes them. Saved on this device.',
+ implemented: true,
+ },
} as const
type PreferenceKeys = keyof typeof preferences
@@ -218,11 +223,14 @@ const defaultPreferences: UserPreferences = {
hideSubdomainLabel: false,
// autoRestart: false,
ramAsNumber: false,
+ reviewChangesBeforePlaying: false,
}
const userPreferences = useStorage(
`pyro-server-${serverId}-preferences`,
defaultPreferences,
+ undefined,
+ { mergeDefaults: true },
)
const newUserPreferences = ref(JSON.parse(JSON.stringify(userPreferences.value)))
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
index 3fa2fb5bc0c..5e662bb1e9d 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
@@ -12,12 +12,14 @@
-
@@ -34,41 +36,11 @@
-
-
-
- {{ formatMessage(messages.downloadModpackButton) }}
-
-
-
- {{ formatMessage(messages.downloadModpackButton) }}
-
-
-
-
-
-
-
-
+
+
+
+ {{ formatMessage(messages.downloadModpackButton) }}
+
@@ -92,26 +64,28 @@
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/share-diff.ts b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/share-diff.ts
new file mode 100644
index 00000000000..c66472cd508
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/share-diff.ts
@@ -0,0 +1,35 @@
+import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
+
+import type { ContentDiffItem } from '#ui/layouts/shared/installation-settings/types'
+
+type Change
= Archon.Content.v1.SharedContentChange
+const before = (change: Change) => 'before' in change ? change.before : undefined
+const after = (change: Change) => 'after' in change ? change.after : undefined
+
+export async function resolveServerShareDiff(client: AbstractModrinthClient, diff: Archon.Content.v1.SharedInstancePublishDiff): Promise {
+ const versionIds = new Set()
+ for (const entry of diff.diffs) {
+ if (entry.type !== 'project' && entry.type !== 'modpack') continue
+ const values = [before(entry.change), after(entry.change)]
+ for (const id of values) if (id) versionIds.add(id)
+ }
+ const versions = await Promise.all([...versionIds].map((id) => client.labrinth.versions_v3.getVersion(id)))
+ const projectIds = [...new Set(versions.map((version) => version.project_id))]
+ const projects = projectIds.length ? await client.labrinth.projects_v3.getMultiple(projectIds) : []
+ const versionName = (id?: string) => versions.find((version) => version.id === id)?.version_number ?? id
+ return diff.diffs.map((entry): ContentDiffItem => {
+ if (entry.type === 'external_file') return { type: entry.kind, fileName: encodeURIComponent(entry.file_name) }
+ if (entry.type === 'loader') {
+ const label = (value?: { name: string; version: string | null }) => value ? [value.name, value.version].filter(Boolean).join(' ') : undefined
+ return { type: 'loader_updated', currentVersionName: label(before(entry.change)), newVersionName: label(after(entry.change)) }
+ }
+ if (entry.type === 'game_version') return { type: 'game_version_updated', currentVersionName: before(entry.change), newVersionName: after(entry.change) }
+ const projectId = entry.type === 'project' ? entry.project_id : versions.find((version) => version.id === (after(entry.change) ?? before(entry.change)))?.project_id
+ return {
+ type: entry.type === 'project' ? entry.change.kind : entry.change.kind === 'added' ? 'modpack_linked' : entry.change.kind === 'removed' ? 'modpack_unlinked' : 'modpack_updated',
+ projectName: projects.find((project) => project.id === projectId)?.title ?? projectId,
+ currentVersionName: versionName(before(entry.change)),
+ newVersionName: versionName(after(entry.change)),
+ }
+ })
+}
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/use-server-players.ts b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/use-server-players.ts
new file mode 100644
index 00000000000..911836080be
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/use-server-players.ts
@@ -0,0 +1,116 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, type Ref } from 'vue'
+
+import type { InviteLinkSettings, InvitePlayersUser } from '#ui/components/sharing'
+import { injectAuth, injectModrinthClient } from '#ui/providers'
+
+import type { ServerPlayerRow } from './types'
+
+export function useServerPlayers(instanceId: Ref, canManage: Ref) {
+ const client = injectModrinthClient()
+ const auth = injectAuth()
+ const queryClient = useQueryClient()
+ const userId = computed(() => auth.user.value?.id)
+ const memberKey = (id: string) => ['shared-instances', id, 'players', userId.value] as const
+ const inviteKey = (id: string) => ['shared-instances', id, 'invites', userId.value] as const
+ const members = useQuery({
+ queryKey: computed(() => memberKey(instanceId.value ?? '')),
+ enabled: computed(() => !!instanceId.value && !!userId.value),
+ queryFn: async () => {
+ const response = await client.sharedinstances.instances_v1.getUsers(instanceId.value!)
+ const users = response.users.length
+ ? await client.labrinth.users_v2.getMultiple(response.users.map((user) => user.id))
+ : []
+ const rows: ServerPlayerRow[] = response.users.filter((user) => user.join_type !== 'owner').map((member) => {
+ const user = users.find((user) => user.id === member.id)
+ return {
+ id: member.id,
+ username: user?.username ?? member.id,
+ avatarUrl: user?.avatar_url ?? undefined,
+ joinedAt: member.joined_at ? new Date(member.joined_at) : null,
+ lastPlayedAt: member.last_played ? new Date(member.last_played) : null,
+ pending: !member.joined_at,
+ method: member.join_type === 'link' ? 'link' : 'direct',
+ }
+ })
+ return { rows, remaining: Math.max(0, 50 - response.users.length - response.tokens) }
+ },
+ refetchInterval: 30_000,
+ })
+ const rows = computed(() => members.data.value?.rows ?? [])
+ const remaining = computed(() => members.data.value?.remaining ?? 0)
+ const friends = useQuery({
+ queryKey: computed(() => ['shared-instances', 'friends', userId.value]),
+ enabled: computed(() => !!userId.value && canManage.value),
+ queryFn: async () => {
+ const currentUserId = userId.value
+ const relationships = await client.labrinth.friends_v3.list()
+ const ids = relationships.filter((friend) => friend.accepted)
+ .map((friend) => friend.id === currentUserId ? friend.friend_id : friend.id)
+ return ids.length ? client.labrinth.users_v2.getMultiple(ids) : []
+ },
+ })
+ const candidates = computed(() => {
+ const candidates = new Map()
+ for (const friend of friends.data.value ?? []) {
+ candidates.set(friend.id, { id: friend.id, username: friend.username, avatarUrl: friend.avatar_url, status: 'available' })
+ }
+ for (const row of rows.value) {
+ candidates.set(row.id, { ...row, status: row.pending ? 'pending' : 'added' })
+ }
+ return [...candidates.values()]
+ })
+ const links = useQuery({
+ queryKey: computed(() => inviteKey(instanceId.value ?? '')),
+ enabled: computed(() => !!instanceId.value && canManage.value),
+ queryFn: () => client.sharedinstances.invites_v1.list(instanceId.value!),
+ })
+ const link = computed(() => links.data.value?.find((link) => new Date(link.expiration).getTime() > Date.now() && link.uses < link.max_uses))
+
+ const membershipMutation = useMutation({
+ mutationFn: async ({ id, userId, remove }: { id: string; userId: string; remove: boolean }) => {
+ if (!canManage.value) throw new Error('You do not have permission to manage players.')
+ if (remove) return client.sharedinstances.instances_v1.removeUsers(id, [userId])
+ const result = await client.sharedinstances.instances_v1.inviteUsers(id, [userId])
+ if (result.failed.includes(userId)) throw new Error('This playerโs privacy settings do not allow this invitation.')
+ },
+ onSettled: (_data, _error, { id }) => queryClient.invalidateQueries({ queryKey: ['shared-instances', id, 'players'] }),
+ })
+ const linkMutation = useMutation({
+ mutationFn: async ({ id, settings, replaceId }: { id: string; settings: InviteLinkSettings; replaceId?: string }) => {
+ if (!canManage.value) throw new Error('You do not have permission to manage invite links.')
+ const created = await client.sharedinstances.invites_v1.create(id, {
+ max_age: Math.max(1, Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000))),
+ max_uses: Math.max(1, Math.min(settings.maxUses, remaining.value)),
+ })
+ if (replaceId) {
+ try {
+ await client.sharedinstances.invites_v1.delete(id, replaceId)
+ } catch (error) {
+ await client.sharedinstances.invites_v1.delete(id, created.id)
+ throw error
+ }
+ }
+ },
+ onSettled: (_data, _error, { id }) => queryClient.invalidateQueries({ queryKey: ['shared-instances', id, 'invites'] }),
+ })
+
+ async function ensureLink(id: string) {
+ const available = await queryClient.fetchQuery({ queryKey: inviteKey(id), queryFn: () => client.sharedinstances.invites_v1.list(id), staleTime: 0 })
+ if (available.some((link) => new Date(link.expiration).getTime() > Date.now() && link.uses < link.max_uses)) return
+ if (remaining.value <= 0) return
+ await linkMutation.mutateAsync({ id, settings: { maxUses: Math.min(10, remaining.value), expiresAt: new Date(Date.now() + 86400_000) } })
+ }
+
+ async function search(query: string) {
+ const users = await queryClient.fetchQuery({
+ queryKey: ['users', 'search', query],
+ queryFn: () => client.labrinth.users_v3.search(query),
+ staleTime: 30_000,
+ })
+ return users.filter((user) => user.id !== userId.value && !candidates.value.some((candidate) => candidate.id === user.id))
+ .map((user) => ({ id: user.id, username: user.username, avatarUrl: user.avatar_url }))
+ }
+
+ return { members, rows, remaining, candidates, link, links, friends, membershipMutation, linkMutation, ensureLink, search }
+}
diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts
index 9c6f8333ed1..89bbbe4ff27 100644
--- a/packages/ui/src/providers/index.ts
+++ b/packages/ui/src/providers/index.ts
@@ -28,3 +28,4 @@ export {
export * from './user-country'
export * from './user-preferences'
export * from './web-notifications'
+export * from './server-play'
diff --git a/packages/ui/src/providers/server-play.ts b/packages/ui/src/providers/server-play.ts
new file mode 100644
index 00000000000..ae5b8d10254
--- /dev/null
+++ b/packages/ui/src/providers/server-play.ts
@@ -0,0 +1,20 @@
+import type { Archon } from '@modrinth/api-client'
+
+import { createContext } from './create-context'
+
+export type ServerPlayTarget = {
+ serverId: string
+ worldId: string
+}
+
+export const [injectServerPlay, provideServerPlay] = createContext<{
+ play: (target: ServerPlayTarget) => Promise
+}>('root', 'serverPlay')
+
+export function getHostingServerAddress(net: Archon.Servers.v0.Net, subdomain?: string) {
+ const domain = net.domain || subdomain
+ if (domain) return domain.includes('.') ? domain : `${domain}.modrinth.gg`
+ if (!net.ip) return ''
+ const host = net.ip.includes(':') && !net.ip.startsWith('[') ? `[${net.ip}]` : net.ip
+ return net.port && net.port !== 25565 ? `${host}:${net.port}` : host
+}
From d5c96361f3d9e6a6a57dcc7177ba262cd12ed7ef Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Thu, 10 Sep 2026 15:31:31 +0100
Subject: [PATCH 08/13] qa: 1
---
.../ui/hosting/HostingPlayHandler.vue | 3 +
.../instances/use-hosting-instance.ts | 89 +++++++++++++++++
.../instance-page-header-server-metadata.vue | 13 ++-
.../src/pages/instance/content/index.vue | 8 +-
.../src/pages/instance/layout.vue | 96 +++++++------------
.../components/project/server/ServerPing.vue | 2 +-
.../project/server/ServerRegion.vue | 16 +++-
.../admonitions/ServerPanelAdmonitions.vue | 75 ++++++++++++++-
.../wrapped/hosting/manage/[id]/play/play.vue | 37 ++++---
9 files changed, 252 insertions(+), 87 deletions(-)
create mode 100644 apps/app-frontend/src/composables/instances/use-hosting-instance.ts
diff --git a/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue b/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
index b952435e5e0..fd230fda5e2 100644
--- a/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
+++ b/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
@@ -23,6 +23,7 @@ import { useRouter } from 'vue-router'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
+import { hostingInstanceMetadata, useHostingInstanceCache } from '@/composables/instances/use-hosting-instance'
import { toError } from '@/helpers/errors'
import { install_job_list, install_get_shared_instance_preview, install_get_shared_instance_update_preview, install_shared_instance, install_update_shared_instance, installJobInstanceId, wait_for_install_job } from '@/helpers/install'
import { get, list } from '@/helpers/instance'
@@ -36,6 +37,7 @@ const client = injectModrinthClient()
const appEvents = injectAppEvents()
const queryClient = useQueryClient()
const router = useRouter()
+const hostingInstances = useHostingInstanceCache()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const accountModal = ref>()
@@ -73,6 +75,7 @@ async function join(target: LaunchTarget, instanceId: string) {
if (!address) throw new Error(formatMessage(messages.noAddress))
await assertAccount(target)
await ensureManagedServerWorldExists(instanceId, target.name, address)
+ hostingInstances.value[instanceId] = hostingInstanceMetadata(server, target.worldId, target.sharedInstanceId, address)
await router.push(`/instance/${encodeURIComponent(instanceId)}`)
await start_join_server(instanceId, address)
}
diff --git a/apps/app-frontend/src/composables/instances/use-hosting-instance.ts b/apps/app-frontend/src/composables/instances/use-hosting-instance.ts
new file mode 100644
index 00000000000..1034b212c74
--- /dev/null
+++ b/apps/app-frontend/src/composables/instances/use-hosting-instance.ts
@@ -0,0 +1,89 @@
+import type { Archon } from '@modrinth/api-client'
+import { getHostingServerAddress, injectAuth, injectModrinthClient } from '@modrinth/ui'
+import { useQuery } from '@tanstack/vue-query'
+import { useStorage } from '@vueuse/core'
+import { computed, type Ref, watch } from 'vue'
+
+import type { GameInstance } from '@/helpers/types'
+import { get_instance_worlds } from '@/helpers/worlds'
+
+type HostingInstanceMetadata = {
+ sharedInstanceId: string
+ serverId: string
+ worldId: string
+ address: string
+ region?: string
+}
+
+export function hostingInstanceMetadata(
+ server: Archon.Servers.v1.ServerFull,
+ worldId: string,
+ sharedInstanceId: string,
+ address: string,
+): HostingInstanceMetadata {
+ const location = server.location
+ return {
+ sharedInstanceId,
+ serverId: server.id,
+ worldId,
+ address,
+ region: location.status === 'assigned' && location.location_metadata.region_should_be_user_displayed
+ ? location.location_metadata.region
+ : undefined,
+ }
+}
+
+export function useHostingInstanceCache() {
+ return useStorage>('hosting-instance-metadata', {})
+}
+
+export function useHostingInstance(instance: Ref, offline: Ref) {
+ const client = injectModrinthClient()
+ const auth = injectAuth()
+ const cache = useHostingInstanceCache()
+ const isHostingInstance = computed(() => !!instance.value?.shared_instance?.server_manager_name)
+ const saved = computed(() => {
+ const current = instance.value
+ const metadata = current && cache.value[current.id]
+ return metadata?.sharedInstanceId === current?.shared_instance?.id ? metadata : undefined
+ })
+ const serverQuery = useQuery({
+ queryKey: computed(() => ['instances', instance.value?.id, 'hosting', auth.user.value?.id]),
+ enabled: computed(() => isHostingInstance.value && !offline.value && !!auth.user.value?.id && auth.user.value.id === instance.value?.shared_instance?.linked_user_id),
+ queryFn: async () => {
+ const current = instance.value!
+ const sharedId = current.shared_instance!.id
+ const known = saved.value
+ const servers = known
+ ? [await client.archon.servers_v1.get(known.serverId)]
+ : await client.archon.servers_v1.list()
+ const server = servers.find((server) => server.worlds.some((world) => world.content?.shared_instance_id === sharedId))
+ const world = server?.worlds.find((world) => world.content?.shared_instance_id === sharedId)
+ if (!server || !world) return null
+ const legacy = await client.archon.servers_v0.get(server.id)
+ return { instanceId: current.id, metadata: hostingInstanceMetadata(server, world.id, sharedId, getHostingServerAddress(legacy.net, server.subdomain)) }
+ },
+ staleTime: 60_000,
+ retry: false,
+ })
+ watch(serverQuery.data, (result) => {
+ if (result) cache.value[result.instanceId] = result.metadata
+ })
+ const worldsQuery = useQuery({
+ queryKey: computed(() => ['instances', instance.value?.id, 'hosting-worlds']),
+ enabled: computed(() => isHostingInstance.value && !saved.value && instance.value?.install_stage === 'installed'),
+ queryFn: () => get_instance_worlds(instance.value!.id),
+ })
+ const address = computed(() => {
+ if (saved.value?.address) return saved.value.address
+ const world = worldsQuery.data.value?.find((world) =>
+ world.type === 'server' && world.name === instance.value?.shared_instance?.server_manager_name,
+ )
+ return world?.type === 'server' ? world.address : undefined
+ })
+ return {
+ isHostingInstance,
+ region: computed(() => saved.value?.region),
+ address,
+ }
+}
diff --git a/apps/app-frontend/src/pages/instance/components/page-header/instance-page-header-server-metadata.vue b/apps/app-frontend/src/pages/instance/components/page-header/instance-page-header-server-metadata.vue
index 04e0db3bfdd..915188e13e7 100644
--- a/apps/app-frontend/src/pages/instance/components/page-header/instance-page-header-server-metadata.vue
+++ b/apps/app-frontend/src/pages/instance/components/page-header/instance-page-header-server-metadata.vue
@@ -4,14 +4,15 @@
-
+
+
import type { Labrinth } from '@modrinth/api-client'
-import { TimerIcon } from '@modrinth/assets'
+import { SpinnerIcon, TimerIcon } from '@modrinth/assets'
import {
+ commonMessages,
PageHeaderMetadata,
PageHeaderMetadataItem,
ServerOnlinePlayers,
ServerPing,
ServerRegion,
+ useVIntl,
} from '@modrinth/ui'
+const { formatMessage } = useVIntl()
+
defineProps<{
loadingServerPing?: boolean
playersOnline?: number
diff --git a/apps/app-frontend/src/pages/instance/content/index.vue b/apps/app-frontend/src/pages/instance/content/index.vue
index 491e336b85e..1a7628c93ce 100644
--- a/apps/app-frontend/src/pages/instance/content/index.vue
+++ b/apps/app-frontend/src/pages/instance/content/index.vue
@@ -288,11 +288,13 @@ watch(projects, (newProjects) => {
})
const mergedProjects = computed(() => {
+ const managedFiles = new Set(managedContentItems.value.map((item) => item.file_name))
+ const additionalProjects = projects.value.filter((item) => !managedFiles.has(item.file_name))
const active = installingItems.value.get(instance.value.id)
- const pending = active ?? installingBuffer.value
- if (pending.length === 0) return projects.value
+ const pending = (active ?? installingBuffer.value).filter((item) => !managedFiles.has(item.file_name))
+ if (pending.length === 0) return additionalProjects
const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean))
- const displayProjects = projects.value.map((project) =>
+ const displayProjects = additionalProjects.map((project) =>
project.project?.id && pendingProjectIds.has(project.project.id)
? { ...project, installing: true }
: project,
diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue
index 7ec73ca240e..d1140970451 100644
--- a/apps/app-frontend/src/pages/instance/layout.vue
+++ b/apps/app-frontend/src/pages/instance/layout.vue
@@ -116,13 +116,13 @@ import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
+import { useHostingInstance } from '@/composables/instances/use-hosting-instance'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
- fetchCachedServerStatus,
- getFreshCachedServerStatus,
+ getServerStatusQueryKey,
} from '@/composables/instances/use-server-status-query'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
@@ -149,7 +149,7 @@ import {
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
-import type { ServerStatus } from '@/helpers/worlds'
+import { get_server_status, start_join_server } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectServerInstall } from '@/providers/server-install'
@@ -259,7 +259,8 @@ const linkedProjectQuery = useQuery(
})),
)
const linkedProjectV3 = computed(() => linkedProjectQuery.data.value ?? undefined)
-const isServerInstance = computed(() => linkedProjectV3.value?.minecraft_server != null)
+const hosting = useHostingInstance(instance, offline)
+const isServerInstance = computed(() => hosting.isHostingInstance.value || linkedProjectV3.value?.minecraft_server != null)
const processesQuery = useQuery(
computed(() => ({
...instanceProcessesQueryOptions(instanceId.value),
@@ -338,13 +339,11 @@ const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useShared
useLoadingBarToken(subpagePending)
useLoadingBarToken(computed(() => instanceQuery.isPending.value && !instance.value))
-const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
+const minecraftServer = computed(() => hosting.isHostingInstance.value
+ ? { region: hosting.region.value }
+ : linkedProjectV3.value?.minecraft_server,
+)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
-const liveServerStatusOnline = ref(false)
-const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
-const playersOnline = ref(undefined)
-const ping = ref(undefined)
-const loadingServerPing = ref(false)
const sharedInstanceState = createSharedInstanceContext(
instance,
offline,
@@ -373,53 +372,19 @@ const sharedInstanceUpdateAvailable = computed(
sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value,
)
-function applyServerStatus(status: ServerStatus) {
- playersOnline.value = status.players?.online
- ping.value = status.ping
- liveServerStatusOnline.value = true
- loadingServerPing.value = true
-}
-
-function resetServerStatus() {
- ping.value = undefined
- playersOnline.value = undefined
- liveServerStatusOnline.value = false
- loadingServerPing.value = false
-}
-
-const serverAddress = computed(() => linkedProjectV3.value?.minecraft_java_server?.address)
-watch(
- [instanceId, serverAddress, isServerInstance],
- ([requestedInstanceId, address, serverInstance]) => {
- resetServerStatus()
- if (serverInstance && address) {
- const cachedStatus = getFreshCachedServerStatus(queryClient, address)
- if (cachedStatus) {
- applyServerStatus(cachedStatus)
- } else {
- playersOnline.value = undefined
- ping.value = undefined
- loadingServerPing.value = false
- }
-
- fetchCachedServerStatus(queryClient, address)
- .then((status) => {
- if (instanceId.value !== requestedInstanceId || serverAddress.value !== address) return
- applyServerStatus(status)
- })
- .catch((error) => {
- console.error(`Failed to fetch server status for ${address}:`, error)
- })
- .finally(() => {
- if (instanceId.value !== requestedInstanceId) return
- loadingServerPing.value = true
- })
- } else {
- loadingServerPing.value = true
- }
- },
- { immediate: true },
-)
+const serverAddress = computed(() => hosting.isHostingInstance.value ? hosting.address.value : linkedProjectV3.value?.minecraft_java_server?.address)
+const serverStatusQuery = useQuery({
+ queryKey: computed(() => getServerStatusQueryKey(serverAddress.value ?? '')),
+ queryFn: () => get_server_status(serverAddress.value!),
+ enabled: computed(() => isServerInstance.value && !!serverAddress.value && !offline.value),
+ staleTime: 30_000,
+ refetchInterval: 30_000,
+ retry: false,
+})
+const statusOnline = computed(() => !serverStatusQuery.isError.value && (!!serverStatusQuery.data.value || !!javaServerPingData.value))
+const playersOnline = computed(() => serverStatusQuery.isError.value ? undefined : serverStatusQuery.data.value?.players?.online)
+const ping = computed(() => serverStatusQuery.isError.value ? undefined : serverStatusQuery.data.value?.ping)
+const loadingServerPing = computed(() => serverStatusQuery.isFetched.value || offline.value)
async function refreshInstance() {
await Promise.all([instanceQuery.refetch(), sharedInstanceState.refreshAvailability()])
@@ -538,12 +503,13 @@ watch(
const options = ref | null>(null)
-const launchInstance = async (context: string) => {
+const launchInstance = async (context: string, address?: string) => {
if (!instance.value || instance.value.quarantined) return
const currentInstance = instance.value
loading.value = true
try {
- await run(currentInstance.id)
+ if (address) await start_join_server(currentInstance.id, address)
+ else await run(currentInstance.id)
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [true])
} catch (err) {
handleSevereError(err, { instanceId: currentInstance.id })
@@ -597,7 +563,7 @@ function handleSharedInstanceUpdateComplete(successful: boolean) {
}
}
-const startInstance = async (context: string) => {
+const startInstance = async (context: string, address?: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
@@ -626,7 +592,7 @@ const startInstance = async (context: string) => {
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await refreshInstance()
- await launchInstance(context)
+ await launchInstance(context, address)
})
return
}
@@ -636,7 +602,7 @@ const startInstance = async (context: string) => {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await refreshInstance()
- await launchInstance(context)
+ await launchInstance(context, address)
})
} else {
updateToPlayModal.value.show(instance.value)
@@ -644,7 +610,7 @@ const startInstance = async (context: string) => {
return
}
- await launchInstance(context)
+ await launchInstance(context, address)
}
const stopInstance = async (context: string) => {
@@ -663,6 +629,10 @@ const stopInstance = async (context: string) => {
}
const handlePlayServer = async () => {
+ if (hosting.isHostingInstance.value) {
+ if (serverAddress.value) await startInstance('InstancePage', serverAddress.value)
+ return
+ }
if (!instance.value?.link?.project_id || instance.value.quarantined) return
loading.value = true
try {
diff --git a/packages/ui/src/components/project/server/ServerPing.vue b/packages/ui/src/components/project/server/ServerPing.vue
index 1d2198b3aa5..706a7d1f4b3 100644
--- a/packages/ui/src/components/project/server/ServerPing.vue
+++ b/packages/ui/src/components/project/server/ServerPing.vue
@@ -46,7 +46,7 @@ const pingClass = computed(() => {
diff --git a/packages/ui/src/components/project/server/ServerRegion.vue b/packages/ui/src/components/project/server/ServerRegion.vue
index 1bf67167421..8ccfb45c168 100644
--- a/packages/ui/src/components/project/server/ServerRegion.vue
+++ b/packages/ui/src/components/project/server/ServerRegion.vue
@@ -3,10 +3,12 @@ import { computed } from 'vue'
import { defineMessage, useVIntl } from '../../../composables'
import { SERVER_REGIONS } from '../../../utils'
+import { regionOverrides } from '../../../utils/regions'
import { TagItem } from '../../base'
-const { region } = defineProps<{
+const { region, flagOnly = false } = defineProps<{
region: string
+ flagOnly?: boolean
}>()
const { formatMessage } = useVIntl()
@@ -17,12 +19,22 @@ const tooltip = defineMessage({
})
const regionName = computed(() => {
+ const hostingRegion = regionOverrides[region as keyof typeof regionOverrides]
+ if (hostingRegion) return formatMessage(hostingRegion.name)
const name = SERVER_REGIONS[region]
if (name) return formatMessage(name)
return region
})
+const regionFlag = computed(() => regionOverrides[region as keyof typeof regionOverrides]?.flag)
- {{ regionName }}
+
+ {{ regionName }}
diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
index a7938c79a9b..7a09eeb172f 100644
--- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
+++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
@@ -1,8 +1,11 @@
diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/sharing-settings/index.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/sharing-settings/index.vue
new file mode 100644
index 00000000000..e65bd8f71c0
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/components/settings-modal/sharing-settings/index.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/sharing-settings/use-sharing-settings.ts b/apps/app-frontend/src/pages/instance/components/settings-modal/sharing-settings/use-sharing-settings.ts
new file mode 100644
index 00000000000..45dc2da233a
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/components/settings-modal/sharing-settings/use-sharing-settings.ts
@@ -0,0 +1,98 @@
+import { injectAuth, type SharingSettingsContext } from '@modrinth/ui'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed } from 'vue'
+
+import {
+ get_shared_instance_invites,
+ revoke_shared_instance_invite,
+ type SharedInstanceInvite,
+ unpublish_shared_instance,
+} from '@/helpers/instance'
+import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
+
+import { instanceKeys } from '../../../query-options.ts'
+import { injectInstanceSettings } from '../instance-settings-context.ts'
+
+export function useInstanceSharingSettings(): SharingSettingsContext {
+ const { instance, offline, onUnlinked } = injectInstanceSettings()
+ const auth = injectAuth()
+ const { notifySharedInstanceError } = useSharedInstanceErrors()
+ const queryClient = useQueryClient()
+ const targetKey = computed(() => {
+ const sharedInstance = instance.value.shared_instance
+ return sharedInstance?.role === 'owner' && !instance.value.quarantined && auth.user.value
+ ? JSON.stringify([instance.value.id, sharedInstance.id, auth.user.value.id])
+ : null
+ })
+ const invitesKey = computed(
+ () => ['sharedInstanceInvites', instance.value.id, auth.user.value?.id] as const,
+ )
+ const invites = useQuery({
+ queryKey: invitesKey,
+ queryFn: ({ queryKey }) => get_shared_instance_invites(queryKey[1]),
+ enabled: () => !!targetKey.value && !offline,
+ retry: false,
+ staleTime: Infinity,
+ refetchOnMount: 'always',
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ })
+
+ type ShareTarget = {
+ instanceId: string
+ invitesKey: typeof invitesKey.value
+ }
+ const revokeMutation = useMutation({
+ mutationFn: (target: ShareTarget & { inviteId: string }) =>
+ revoke_shared_instance_invite(target.instanceId, target.inviteId),
+ onSuccess: (_data, target) => {
+ queryClient.setQueryData(target.invitesKey, (current = []) =>
+ current.filter((invite) => invite.id !== target.inviteId),
+ )
+ },
+ })
+ const unpublishMutation = useMutation({
+ mutationFn: (target: ShareTarget) => unpublish_shared_instance(target.instanceId),
+ onSuccess: async (_data, target) => {
+ queryClient.setQueryData(instanceKeys.sharedMembers(target.instanceId), [])
+ queryClient.setQueryData(target.invitesKey, [])
+ await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', target.instanceId] })
+ if (target.instanceId === instance.value.id && target.invitesKey[2] === auth.user.value?.id) {
+ onUnlinked()
+ }
+ },
+ })
+ const busy = computed(
+ () =>
+ !targetKey.value ||
+ !!offline ||
+ instance.value.install_stage !== 'installed' ||
+ revokeMutation.isPending.value ||
+ unpublishMutation.isPending.value,
+ )
+
+ function currentTarget(expectedKey: string): ShareTarget | undefined {
+ if (busy.value || expectedKey !== targetKey.value) return
+ return { instanceId: instance.value.id, invitesKey: invitesKey.value }
+ }
+
+ return {
+ targetKey,
+ invites: computed(() => invites.data.value ?? []),
+ loading: invites.isLoading,
+ error: invites.error,
+ busy,
+ refresh: async () => {
+ if (targetKey.value && !offline) await invites.refetch()
+ },
+ revokeInvite: async (inviteId, expectedKey) => {
+ const target = currentTarget(expectedKey)
+ if (target) await revokeMutation.mutateAsync({ ...target, inviteId })
+ },
+ unpublish: async (expectedKey) => {
+ const target = currentTarget(expectedKey)
+ if (target) await unpublishMutation.mutateAsync(target)
+ },
+ onError: notifySharedInstanceError,
+ }
+}
diff --git a/apps/app-frontend/src/pages/instance/share/index.vue b/apps/app-frontend/src/pages/instance/share/index.vue
index d88eed0d3ce..d889a75de13 100644
--- a/apps/app-frontend/src/pages/instance/share/index.vue
+++ b/apps/app-frontend/src/pages/instance/share/index.vue
@@ -57,16 +57,19 @@
-
+ type="warning"
+ :header="formatMessage(sharedInstanceUnavailableTitleMessage(sharedInstanceUnavailableReason))"
+ >
+ {{ formatSharedInstanceUnavailable(sharedInstanceUnavailableReason, sharedInstanceUnavailableManager) }}
+
+
+
+ {{ formatMessage(commonMessages.retryButton) }}
+
+
+
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import {
+ Admonition,
Avatar,
Button,
+ commonMessages,
ConfirmUnlinkModal,
defineMessages,
injectAuth,
@@ -154,7 +159,7 @@ import {
import { edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
- sharedInstanceErrorMessages,
+ sharedInstanceUnavailableTitleMessage,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
@@ -185,6 +190,16 @@ const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
+const retryingAvailability = ref(false)
+async function retryAvailability() {
+ if (retryingAvailability.value) return
+ retryingAvailability.value = true
+ try {
+ await sharedInstanceState.refreshAvailability()
+ } finally {
+ retryingAvailability.value = false
+ }
+}
const sharedInstancesApiUnavailable = ref(false)
const accountRequiredModal = ref>()
const invitePlayersModal = ref>()
diff --git a/packages/api-client/src/modules/archon/content/v1.ts b/packages/api-client/src/modules/archon/content/v1.ts
index 1720d758b78..ccb7a03fe68 100644
--- a/packages/api-client/src/modules/archon/content/v1.ts
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -18,6 +18,15 @@ export class ArchonContentV1Module extends AbstractModule {
})
}
+ public async unshare(serverId: string, worldId: string): Promise {
+ return this.client.request(`/servers/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId)}/content/share`, {
+ api: 'archon',
+ version: 1,
+ method: 'DELETE',
+ retry: false,
+ })
+ }
+
/** GET /v1/:server_id/worlds/:world_id/addons */
public async getAddons(
serverId: string,
diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts
index 22c68532943..b8602783058 100644
--- a/packages/api-client/src/modules/archon/types.ts
+++ b/packages/api-client/src/modules/archon/types.ts
@@ -306,6 +306,12 @@ export namespace Archon {
local_updated_at: string
has_changes: boolean
diffs: SharedContentDiffEntry[]
+ projects: Record
+ versions: Record
}
export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack'
@@ -1172,6 +1178,12 @@ export namespace Archon {
needs_update: boolean
}
+ export type WorldSharedInstanceDeleteEvent = {
+ type: 'world.shared_instance.delete'
+ world_id: string
+ shared_instance_id: string
+ }
+
export type SyncEvent =
| ProtocolResetEvent
| ProtocolInvalidEvent
@@ -1192,6 +1204,7 @@ export namespace Archon {
| WorldContentBaseUpdateEvent
| WorldContentUpdateEvent
| WorldSharedInstanceUpdateEvent
+ | WorldSharedInstanceDeleteEvent
}
}
diff --git a/packages/app-lib/src/api/instance/shared/install.rs b/packages/app-lib/src/api/instance/shared/install.rs
index f2de99b2c4f..ea422033e28 100644
--- a/packages/app-lib/src/api/instance/shared/install.rs
+++ b/packages/app-lib/src/api/instance/shared/install.rs
@@ -439,6 +439,12 @@ pub(super) async fn handle_unavailable_shared_instance_if_current_user(
reason: SharedInstanceUnavailableReason,
state: &State,
) -> crate::Result<()> {
+ // A missing ID can belong to another API environment. Keep the attachment
+ // so opening a production instance in a staging build cannot unlink it.
+ if reason == SharedInstanceUnavailableReason::Deleted {
+ return Ok(());
+ }
+
if reason != SharedInstanceUnavailableReason::Quarantined
&& !shared_attachment_matches_current_user(attachment, state).await?
{
diff --git a/packages/ui/src/components/base/NavTabs.vue b/packages/ui/src/components/base/NavTabs.vue
index 1958a4498b8..b74ecd34e6a 100644
--- a/packages/ui/src/components/base/NavTabs.vue
+++ b/packages/ui/src/components/base/NavTabs.vue
@@ -182,6 +182,8 @@ function computeActiveIndex(): { index: number; isSubpage: boolean } {
}
}
+ let subpageIndex = -1
+
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
const decodedPath = decodeURIComponent(route.path)
@@ -204,12 +206,12 @@ function computeActiveIndex(): { index: number; isSubpage: boolean } {
(decodedPath.length === decodedHref.length || decodedPath[decodedHref.length] === '/')) ||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
- if (isSubpageMatch) {
- return { index: i, isSubpage: true }
+ if (isSubpageMatch && subpageIndex === -1) {
+ subpageIndex = i
}
}
- return { index: -1, isSubpage: false }
+ return { index: subpageIndex, isSubpage: subpageIndex !== -1 }
}
function getTabElement(index: number): HTMLElement | null {
diff --git a/packages/ui/src/components/base/inputs/Input.vue b/packages/ui/src/components/base/inputs/Input.vue
index ab805a9aa0f..9f1b5d5cf71 100644
--- a/packages/ui/src/components/base/inputs/Input.vue
+++ b/packages/ui/src/components/base/inputs/Input.vue
@@ -28,35 +28,52 @@
-
+
+ {{ hasValue ? model : placeholder || ' ' }}
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/ServerSettingsModal.vue b/packages/ui/src/components/servers/ServerSettingsModal.vue
index 1f6f66ca2bb..ac93c46960f 100644
--- a/packages/ui/src/components/servers/ServerSettingsModal.vue
+++ b/packages/ui/src/components/servers/ServerSettingsModal.vue
@@ -2,7 +2,7 @@
import type { Archon } from '@modrinth/api-client'
import { ChevronRightIcon } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
-import { computed, nextTick, ref } from 'vue'
+import { computed, nextTick, ref, watch } from 'vue'
import type { TabbedModalTab } from '#ui/components'
import { TabbedModal } from '#ui/components'
@@ -13,6 +13,7 @@ import {
ServerSettingsInstallationPage,
ServerSettingsNetworkPage,
ServerSettingsPropertiesPage,
+ ServerSettingsSharingPage,
serverSettingsTabDefinitions,
type ServerSettingsTabId,
} from '#ui/layouts/shared/server-settings'
@@ -31,6 +32,7 @@ type ShowOptions = {
}
const props = defineProps<{
+ siteUrl?: string
resolveViewer: () => Promise<{ userId: string | null; userRole: string | null }>
browseModpacks?: (args: {
serverId: string
@@ -53,7 +55,7 @@ const messages = defineMessages({
const modal = ref | null>(null)
-const { serverId: currentServerId, worldId, server } = injectModrinthServerContext()
+const { serverId: currentServerId, worldId, server, serverFull } = injectModrinthServerContext()
const currentUserId = ref(null)
const currentUserRole = ref(null)
@@ -74,6 +76,7 @@ useQuery({
const serverSettingsTabComponentMap = {
general: ServerSettingsGeneralPage,
installation: ServerSettingsInstallationPage,
+ sharing: ServerSettingsSharingPage,
network: ServerSettingsNetworkPage,
properties: ServerSettingsPropertiesPage,
advanced: ServerSettingsAdvancedPage,
@@ -83,6 +86,7 @@ const saveBannerTarget = ref(null)
const saveBannerShown = ref(false)
provideServerSettings({
+ siteUrl: computed(() => (props.siteUrl ?? 'https://modrinth.com').replace(/\/$/, '')),
isApp,
currentUserId,
currentUserRole,
@@ -103,6 +107,8 @@ const tabs = computed(() =>
serverStatus: server.value?.status,
isOwner: isOwner.value,
isAdmin: isAdmin.value,
+ isShared: !!serverFull.value?.worlds.find((world) => world.id === worldId.value)?.content
+ ?.shared_instance_id,
}
const name = defineMessage({
id: `server.settings.tabs.${tab.id}`,
@@ -128,6 +134,15 @@ const tabs = computed(() =>
}),
)
+watch(tabs, (currentTabs, previousTabs) => {
+ if (!modal.value) return
+ const selectedTab = previousTabs.filter((tab) => tab.shown !== false)[modal.value.selectedTab]
+ const selectedIndex = currentTabs
+ .filter((tab) => tab.shown !== false)
+ .findIndex((tab) => tab.content === selectedTab?.content)
+ modal.value.setTab(Math.max(selectedIndex, 0))
+})
+
async function fetchViewer() {
currentUserId.value = null
currentUserRole.value = null
diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
index 7a09eeb172f..08a45cfab50 100644
--- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
+++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
@@ -1,8 +1,8 @@
+
+
+ {{ permissionDeniedMessage }}
+ {{ formatMessage(messages.notShared) }}
+
+
diff --git a/packages/ui/src/layouts/shared/server-settings/providers/server-settings.ts b/packages/ui/src/layouts/shared/server-settings/providers/server-settings.ts
index 59abf87f198..fa2250c8f37 100644
--- a/packages/ui/src/layouts/shared/server-settings/providers/server-settings.ts
+++ b/packages/ui/src/layouts/shared/server-settings/providers/server-settings.ts
@@ -9,6 +9,7 @@ export interface ServerSettingsBrowseModpacksArgs {
}
export interface ServerSettingsContext {
+ siteUrl: Ref
isApp: Ref
currentUserId: Ref
currentUserRole: Ref
diff --git a/packages/ui/src/layouts/shared/server-settings/tabs.ts b/packages/ui/src/layouts/shared/server-settings/tabs.ts
index 7dcb5802459..74017484286 100644
--- a/packages/ui/src/layouts/shared/server-settings/tabs.ts
+++ b/packages/ui/src/layouts/shared/server-settings/tabs.ts
@@ -4,6 +4,7 @@ import {
ListIcon,
ModrinthIcon,
SettingsIcon,
+ UsersIcon,
TextQuoteIcon,
VersionIcon,
WrenchIcon,
@@ -13,6 +14,7 @@ import type { Component } from 'vue'
export type ServerSettingsTabId =
| 'general'
| 'installation'
+ | 'sharing'
| 'network'
| 'properties'
| 'advanced'
@@ -25,6 +27,7 @@ export interface ServerSettingsTabContext {
serverStatus?: Archon.Servers.v0.Status | null
isOwner: boolean
isAdmin: boolean
+ isShared: boolean
}
export interface ServerSettingsTabDefinition {
@@ -47,6 +50,12 @@ export const serverSettingsTabDefinitions: ServerSettingsTabDefinition[] = [
label: 'Installation',
icon: WrenchIcon,
},
+ {
+ id: 'sharing',
+ label: 'Sharing',
+ icon: UsersIcon,
+ shown: ({ isShared }) => isShared,
+ },
{
id: 'network',
label: 'Network',
diff --git a/packages/ui/src/layouts/shared/server-sharing/cache.ts b/packages/ui/src/layouts/shared/server-sharing/cache.ts
new file mode 100644
index 00000000000..fab0913ff68
--- /dev/null
+++ b/packages/ui/src/layouts/shared/server-sharing/cache.ts
@@ -0,0 +1,39 @@
+import type { Archon } from '@modrinth/api-client'
+import type { QueryClient } from '@tanstack/vue-query'
+
+export async function clearServerSharedInstance(
+ queryClient: QueryClient,
+ serverId: string,
+ worldId: string,
+ sharedInstanceId: string,
+) {
+ const detailKey = ['servers', 'v1', 'detail', serverId] as const
+ const instanceKey = ['shared-instances', sharedInstanceId] as const
+ const diffKey = ['servers', 'share-diff', serverId, worldId] as const
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: detailKey }),
+ queryClient.cancelQueries({ queryKey: instanceKey }),
+ queryClient.cancelQueries({ queryKey: diffKey }),
+ ])
+ queryClient.setQueryData(detailKey, (server) =>
+ server
+ ? {
+ ...server,
+ worlds: server.worlds.map((world) =>
+ world.id === worldId && world.content?.shared_instance_id === sharedInstanceId
+ ? {
+ ...world,
+ content: {
+ ...world.content,
+ shared_instance_id: null,
+ shared_instance_needs_update: false,
+ },
+ }
+ : world,
+ ),
+ }
+ : server,
+ )
+ queryClient.removeQueries({ queryKey: instanceKey })
+ queryClient.removeQueries({ queryKey: diffKey })
+}
diff --git a/packages/ui/src/layouts/shared/server-sharing/index.ts b/packages/ui/src/layouts/shared/server-sharing/index.ts
new file mode 100644
index 00000000000..e0c6049e1d9
--- /dev/null
+++ b/packages/ui/src/layouts/shared/server-sharing/index.ts
@@ -0,0 +1,3 @@
+export { clearServerSharedInstance } from './cache'
+export { sharedInstanceInvitesQueryOptions } from './query-options'
+export { useServerSharingSettings } from './use-server-sharing'
diff --git a/packages/ui/src/layouts/shared/server-sharing/query-options.ts b/packages/ui/src/layouts/shared/server-sharing/query-options.ts
new file mode 100644
index 00000000000..532f9dceee1
--- /dev/null
+++ b/packages/ui/src/layouts/shared/server-sharing/query-options.ts
@@ -0,0 +1,13 @@
+import type { AbstractModrinthClient } from '@modrinth/api-client'
+
+export function sharedInstanceInvitesQueryOptions(
+ client: AbstractModrinthClient,
+ instanceId: string,
+ userId: string | undefined,
+) {
+ return {
+ queryKey: ['shared-instances', instanceId, 'invites', userId] as const,
+ queryFn: () => client.sharedinstances.invites_v1.list(instanceId),
+ retry: false,
+ }
+}
diff --git a/packages/ui/src/layouts/shared/server-sharing/use-server-sharing.ts b/packages/ui/src/layouts/shared/server-sharing/use-server-sharing.ts
new file mode 100644
index 00000000000..57026f31338
--- /dev/null
+++ b/packages/ui/src/layouts/shared/server-sharing/use-server-sharing.ts
@@ -0,0 +1,120 @@
+import type { SharedInstances } from '@modrinth/api-client'
+import { useIsMutating, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { useStorage } from '@vueuse/core'
+import { computed } from 'vue'
+
+import { useServerPermissions } from '#ui/composables/server-permissions'
+import {
+ injectAuth,
+ injectModrinthClient,
+ injectModrinthServerContext,
+ injectNotificationManager,
+} from '#ui/providers'
+
+import type { SharingSettingsContext } from '../sharing-settings'
+import { clearServerSharedInstance } from './cache'
+import { sharedInstanceInvitesQueryOptions } from './query-options'
+
+export function useServerSharingSettings() {
+ const client = injectModrinthClient()
+ const auth = injectAuth()
+ const queryClient = useQueryClient()
+ const { handleError } = injectNotificationManager()
+ const { serverId, worldId, serverFull, busyReasons } = injectModrinthServerContext()
+ const { canSetup, permissionDeniedMessage } = useServerPermissions()
+ const preferences = useStorage(
+ `pyro-server-${serverId}-preferences`,
+ { reviewChangesBeforePlaying: false },
+ undefined,
+ { mergeDefaults: true },
+ )
+ const userId = computed(() => auth.user.value?.id)
+ const sharedInstanceId = computed(() =>
+ serverFull.value?.worlds.find((world) => world.id === worldId.value)?.content?.shared_instance_id,
+ )
+ const targetKey = computed(() =>
+ canSetup.value && worldId.value && sharedInstanceId.value && userId.value
+ ? JSON.stringify([serverId, worldId.value, sharedInstanceId.value, userId.value])
+ : null,
+ )
+ const invites = useQuery(
+ computed(() => ({
+ ...sharedInstanceInvitesQueryOptions(client, sharedInstanceId.value ?? '', userId.value),
+ enabled: !!targetKey.value,
+ refetchOnMount: 'always' as const,
+ refetchInterval: 30_000,
+ })),
+ )
+ const shareActions = useIsMutating({ mutationKey: ['servers', 'share-action', serverId] })
+
+ type ShareTarget = { worldId: string; instanceId: string }
+ const revokeMutation = useMutation({
+ mutationFn: (target: ShareTarget & { inviteId: string }) =>
+ client.sharedinstances.invites_v1.delete(target.instanceId, target.inviteId),
+ onSuccess: (_data, target) => {
+ queryClient.setQueriesData(
+ { queryKey: ['shared-instances', target.instanceId, 'invites'] },
+ (current) => current?.filter((invite) => invite.id !== target.inviteId),
+ )
+ },
+ onSettled: (_data, _error, target) =>
+ queryClient.invalidateQueries({ queryKey: ['shared-instances', target.instanceId, 'invites'] }),
+ })
+ const unpublishMutation = useMutation({
+ mutationKey: ['servers', 'share-action', serverId],
+ mutationFn: (target: ShareTarget) => client.archon.content_v1.unshare(serverId, target.worldId),
+ onSuccess: (_data, target) =>
+ clearServerSharedInstance(queryClient, serverId, target.worldId, target.instanceId),
+ onSettled: () => queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] }),
+ })
+ const busy = computed(
+ () =>
+ !targetKey.value ||
+ busyReasons.value.length > 0 ||
+ shareActions.value > 0 ||
+ revokeMutation.isPending.value ||
+ unpublishMutation.isPending.value,
+ )
+
+ function currentTarget(expectedKey: string): ShareTarget | undefined {
+ if (busy.value || expectedKey !== targetKey.value || !worldId.value || !sharedInstanceId.value) {
+ return
+ }
+ return { worldId: worldId.value, instanceId: sharedInstanceId.value }
+ }
+
+ const settings: SharingSettingsContext = {
+ targetKey,
+ invites: computed(() =>
+ (invites.data.value ?? []).map((invite) => ({
+ id: invite.id,
+ expiration: invite.expiration,
+ maxUses: invite.max_uses,
+ uses: invite.uses,
+ })),
+ ),
+ loading: invites.isLoading,
+ error: invites.error,
+ busy,
+ refresh: async () => {
+ if (targetKey.value) await invites.refetch()
+ },
+ revokeInvite: async (inviteId, expectedKey) => {
+ const target = currentTarget(expectedKey)
+ if (target) await revokeMutation.mutateAsync({ ...target, inviteId })
+ },
+ unpublish: async (expectedKey) => {
+ const target = currentTarget(expectedKey)
+ if (target) await unpublishMutation.mutateAsync(target)
+ },
+ onError: (error) => handleError(error),
+ reviewChangesBeforePlaying: computed({
+ get: () => preferences.value.reviewChangesBeforePlaying,
+ set: (value) => {
+ preferences.value.reviewChangesBeforePlaying = value
+ },
+ }),
+ }
+
+ return { settings, canSetup, permissionDeniedMessage, sharedInstanceId }
+}
diff --git a/packages/ui/src/layouts/shared/sharing-settings/components/active-invites.vue b/packages/ui/src/layouts/shared/sharing-settings/components/active-invites.vue
new file mode 100644
index 00000000000..33515907236
--- /dev/null
+++ b/packages/ui/src/layouts/shared/sharing-settings/components/active-invites.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+ {{ formatMessage(messages.activeInvitesTitle) }}
+
+
{{ formatMessage(messages.activeInvitesDescription) }}
+
+
+ {{ formatMessage(messages.retry) }}
+
+
+
+
+
+ {{ formatMessage(messages.noInvites) }}
+
+
+
+
+
+
+ {{ row.uses }}
+ / {{ row.maxUses }}
+
+
+
+ {{ formatRelativeTime(row.expiration) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/confirm-revoke-shared-instance-invite-modal.vue b/packages/ui/src/layouts/shared/sharing-settings/components/revoke-invite-modal.vue
similarity index 72%
rename from apps/app-frontend/src/pages/instance/components/settings-modal/confirm-revoke-shared-instance-invite-modal.vue
rename to packages/ui/src/layouts/shared/sharing-settings/components/revoke-invite-modal.vue
index 89fd9f6cd8f..0db1f327c3a 100644
--- a/apps/app-frontend/src/pages/instance/components/settings-modal/confirm-revoke-shared-instance-invite-modal.vue
+++ b/packages/ui/src/layouts/shared/sharing-settings/components/revoke-invite-modal.vue
@@ -1,7 +1,7 @@
-
+
@@ -14,7 +14,7 @@
{{ formatMessage(commonMessages.cancelButton) }}
-
+
{{ formatMessage(messages.revokeButton) }}
@@ -25,34 +25,19 @@
diff --git a/packages/ui/src/layouts/shared/sharing-settings/components/unpublish-modal.vue b/packages/ui/src/layouts/shared/sharing-settings/components/unpublish-modal.vue
new file mode 100644
index 00000000000..5356282094a
--- /dev/null
+++ b/packages/ui/src/layouts/shared/sharing-settings/components/unpublish-modal.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+ {{ formatMessage(description) }}
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+ {{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/sharing-settings/index.ts b/packages/ui/src/layouts/shared/sharing-settings/index.ts
new file mode 100644
index 00000000000..fa4e1445363
--- /dev/null
+++ b/packages/ui/src/layouts/shared/sharing-settings/index.ts
@@ -0,0 +1,3 @@
+export { default as SharingSettingsLayout } from './layout.vue'
+export * from './providers/sharing-settings'
+export * from './types'
diff --git a/packages/ui/src/layouts/shared/sharing-settings/layout.vue b/packages/ui/src/layouts/shared/sharing-settings/layout.vue
new file mode 100644
index 00000000000..5bdd71e98a5
--- /dev/null
+++ b/packages/ui/src/layouts/shared/sharing-settings/layout.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.unpublishTitle) }}
+
+
+
+
+
+ {{ formatMessage(unpublishing ? messages.unpublishing : messages.unpublish) }}
+
+
+ {{ formatMessage(unpublishDescription) }}
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/sharing-settings/providers/sharing-settings.ts b/packages/ui/src/layouts/shared/sharing-settings/providers/sharing-settings.ts
new file mode 100644
index 00000000000..d8e6f27ea9f
--- /dev/null
+++ b/packages/ui/src/layouts/shared/sharing-settings/providers/sharing-settings.ts
@@ -0,0 +1,21 @@
+import type { Ref } from 'vue'
+
+import { createContext } from '#ui/providers/create-context'
+
+import type { SharingInvite } from '../types'
+
+export interface SharingSettingsContext {
+ targetKey: Readonly[>
+ invites: Readonly][>
+ loading: Readonly][>
+ error: Readonly][>
+ busy: Readonly][>
+ refresh: () => Promise
+ revokeInvite: (inviteId: string, targetKey: string) => Promise
+ unpublish: (targetKey: string) => Promise
+ onError: (error: unknown) => void
+ reviewChangesBeforePlaying?: Ref
+}
+
+export const [injectSharingSettings, provideSharingSettings] =
+ createContext('SharingSettingsLayout', 'sharingSettings')
diff --git a/packages/ui/src/layouts/shared/sharing-settings/types.ts b/packages/ui/src/layouts/shared/sharing-settings/types.ts
new file mode 100644
index 00000000000..288ea7ff398
--- /dev/null
+++ b/packages/ui/src/layouts/shared/sharing-settings/types.ts
@@ -0,0 +1,6 @@
+export interface SharingInvite {
+ id: string
+ expiration: string
+ maxUses: number
+ uses: number
+}
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
index 5e662bb1e9d..80abb4a6dc8 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue
@@ -41,21 +41,18 @@
{{ formatMessage(messages.downloadModpackButton) }}
]
-
- {{ address }}
-
-
-
-
-
+ {{ address }}
+
+
+
@@ -64,7 +61,7 @@
-
+
diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue
index d1140970451..7a967d20880 100644
--- a/apps/app-frontend/src/pages/instance/layout.vue
+++ b/apps/app-frontend/src/pages/instance/layout.vue
@@ -36,7 +36,7 @@
:show-instance-play-time="showInstancePlayTime"
:time-played="timePlayed"
:playing="playing"
- :loading="loading"
+ :loading="loading || checkingSharedInstanceLaunch || instanceLaunch.isStarting(instance.id)"
:stopping="stopping"
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
@@ -125,6 +125,7 @@ import {
getServerStatusQueryKey,
} from '@/composables/instances/use-server-status-query'
import { useAppEvent } from '@/composables/use-app-event'
+import { useInstanceLaunchState } from '@/composables/instances/use-instance-launch-state'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { handleSevereError } from '@/composables/use-error.js'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
@@ -322,6 +323,7 @@ useRootBreadcrumb({
})
const loading = ref(false)
+const instanceLaunch = useInstanceLaunchState()
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
@@ -508,9 +510,11 @@ const launchInstance = async (context: string, address?: string) => {
const currentInstance = instance.value
loading.value = true
try {
- if (address) await start_join_server(currentInstance.id, address)
- else await run(currentInstance.id)
- queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [true])
+ await instanceLaunch.run(currentInstance.id, async () => {
+ if (address) await start_join_server(currentInstance.id, address)
+ else await run(currentInstance.id)
+ queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [true])
+ })
} catch (err) {
handleSevereError(err, { instanceId: currentInstance.id })
}
@@ -565,7 +569,7 @@ function handleSharedInstanceUpdateComplete(successful: boolean) {
const startInstance = async (context: string, address?: string) => {
if (!instance.value || instance.value.quarantined) return
- if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
+ if (checkingSharedInstanceLaunch.value || loading.value || playing.value || instanceLaunch.isStarting(instance.value.id)) return
const instanceId = instance.value.id
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
index 35d81cabc0d..48fc6dac4f4 100644
--- a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
@@ -1,175 +1,42 @@
-
-
-
-
-
-
-
-
- {{ formatMessage(messages.pushUpdate) }}
-
-
-
-
- Invite friends
-
-
-
-
-
-
- All
-
-
- {{ option.label }}
-
-
-
-
-
-
- {{
- formatMessage(rows.length === 0 ? messages.noUsersJoined : messages.noUsersMatchFilters)
- }}
-
+
+
+
+
+ {{ formatMessage(messages.pushUpdate) }}
+
+
-
-
-
-
- {{ row.username }}
-
-
-
-
- {{
- formatRelativeTime(row.lastPlayedAt)
- }}
- Never
-
-
- Pending
- {{
- formatRelativeTime(row.joinedAt)
- }}
-
-
-
-
-
- {{ methodLabels[row.method] }}
-
-
-
-
-
-
-
-
-
-
+
+
+ Invite friends
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
index a9bebd01c25..9938247ea0f 100644
--- a/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
@@ -1,22 +1,8 @@
-export type ShareMethod = 'direct' | 'link'
-export type MethodFilter = ShareMethod | 'all'
-export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
+export {
+ invitedPlayerMethodLabels as methodLabels,
+ normalizeInviteKey,
+ type InvitedPlayerMethod as ShareMethod,
+ type InvitedPlayerRow as ShareRow,
+} from '@modrinth/ui'
export const SHARED_INSTANCE_USER_LIMIT = 50
-
-export type ShareRow = {
- id: string
- username: string
- avatarUrl?: string
- lastPlayedAt: Date | null
- joinedAt: Date | null
- method: ShareMethod
- pending?: boolean
-}
-
-export const methodLabels: Record
= {
- direct: 'Direct invite',
- link: 'Share link',
-}
-
-export { normalizeInviteKey } from '@modrinth/ui'
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
index b08053e33eb..bc72394dcfa 100644
--- a/apps/app/capabilities/plugins.json
+++ b/apps/app/capabilities/plugins.json
@@ -2,7 +2,9 @@
"identifier": "plugins",
"description": "",
"local": true,
- "windows": ["main"],
+ "windows": [
+ "main"
+ ],
"permissions": [
"dialog:allow-open",
"dialog:allow-confirm",
@@ -31,6 +33,9 @@
{
"url": "https://*.nodes.modrinth.com/*"
},
+ {
+ "url": "https://*.tail029726.ts.net/*"
+ },
{
"url": "https://api.mclo.gs/*"
},
diff --git a/packages/api-client/src/modules/archon/content/v1.ts b/packages/api-client/src/modules/archon/content/v1.ts
index ccb7a03fe68..a4a20939449 100644
--- a/packages/api-client/src/modules/archon/content/v1.ts
+++ b/packages/api-client/src/modules/archon/content/v1.ts
@@ -6,9 +6,10 @@ export class ArchonContentV1Module extends AbstractModule {
return 'archon_content_v1'
}
- public async share(serverId: string, worldId: string): Promise {
+ public async share(serverId: string, worldId: string, configPaths: string[] = []): Promise {
return this.client.request(`/servers/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId)}/content/share`, {
api: 'archon', version: 1, method: 'POST', timeout: 600_000, retry: false,
+ body: { config_paths: configPaths },
})
}
diff --git a/packages/api-client/src/modules/shared-instances/types.ts b/packages/api-client/src/modules/shared-instances/types.ts
index c41d30a55e9..773039d06fe 100644
--- a/packages/api-client/src/modules/shared-instances/types.ts
+++ b/packages/api-client/src/modules/shared-instances/types.ts
@@ -48,6 +48,7 @@ export namespace SharedInstances {
name: string
icon: string | null
quarantine: boolean
+ linked_server: { domain: string; region: string } | null
}
export type JoinType = 'owner' | 'invite' | 'link'
diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs
index 8a0040f46a3..b80235b085b 100644
--- a/packages/app-lib/src/api/instance.rs
+++ b/packages/app-lib/src/api/instance.rs
@@ -76,6 +76,7 @@ pub use self::screenshots::{
};
pub(crate) use self::shared::{
CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS,
+ CONFIG_FILE_TYPE, MAX_CONFIG_BUNDLE_FILE_SIZE,
CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES,
read_bounded_config_bundle_entry,
};
diff --git a/packages/app-lib/src/api/instance/shared/client.rs b/packages/app-lib/src/api/instance/shared/client.rs
index bedd567b3fc..8982213a86f 100644
--- a/packages/app-lib/src/api/instance/shared/client.rs
+++ b/packages/app-lib/src/api/instance/shared/client.rs
@@ -34,6 +34,11 @@ pub(super) enum SharedInstanceRemoteResponse {
#[derive(Clone, Debug, Deserialize)]
pub(super) struct RemoteInstanceResponse {
+ pub(super) name: String,
+ pub(super) icon: Option,
+ #[serde(default)]
+ pub(super) linked_server:
+ Option,
#[serde(default)]
pub(super) quarantine: bool,
}
@@ -242,7 +247,7 @@ pub(super) async fn update_remote_instance(
pub(super) async fn get_remote_instance_access(
shared_instance_id: &str,
state: &State,
-) -> crate::Result> {
+) -> crate::Result> {
let operation = "get_instance";
let method = Method::GET;
let path = format!("/instances/{shared_instance_id}");
@@ -278,7 +283,7 @@ pub(super) async fn get_remote_instance_access(
));
}
- Ok(SharedInstanceRemoteResponse::Available(()))
+ Ok(SharedInstanceRemoteResponse::Available(instance))
}
pub(super) async fn update_remote_instance_icon(
diff --git a/packages/app-lib/src/api/instance/shared/diff.rs b/packages/app-lib/src/api/instance/shared/diff.rs
index 357b9bd0ac4..82c6bca407b 100644
--- a/packages/app-lib/src/api/instance/shared/diff.rs
+++ b/packages/app-lib/src/api/instance/shared/diff.rs
@@ -32,7 +32,7 @@ pub(super) async fn shared_instance_update_diffs(
configuration: after_configuration,
};
- shared_content_diffs(
+ let mut diffs = shared_content_diffs(
&before,
&after,
&HashSet::new(),
@@ -40,7 +40,24 @@ pub(super) async fn shared_instance_update_diffs(
CommonExternalFilePolicy::AssumeUpdated,
state,
)
- .await
+ .await?;
+ let config_file_count = version.external_files
+ .iter()
+ .filter(|file| file.file_type == CONFIG_FILE_TYPE)
+ .count();
+ if config_file_count > 0 {
+ diffs.push(SharedInstanceUpdateDiff {
+ type_: SharedInstanceUpdateDiffType::ConfigFilesUpdated,
+ project_id: None,
+ project_name: None,
+ file_name: None,
+ current_version_name: None,
+ new_version_name: None,
+ config_file_count: Some(config_file_count),
+ disabled: false,
+ });
+ }
+ Ok(diffs)
}
pub(super) async fn shared_instance_publish_diffs(
@@ -456,7 +473,7 @@ fn remote_shared_content(
let external_files = version
.external_files
.iter()
- .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
+ .filter(|file| !matches!(file.file_type.as_str(), CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE))
.map(|file| shared_external_file_key(&file.file_type, &file.file_name))
.collect::>()?;
Ok((version_ids, external_files))
diff --git a/packages/app-lib/src/api/instance/shared/install.rs b/packages/app-lib/src/api/instance/shared/install.rs
index ea422033e28..9bb71f77a4b 100644
--- a/packages/app-lib/src/api/instance/shared/install.rs
+++ b/packages/app-lib/src/api/instance/shared/install.rs
@@ -142,7 +142,7 @@ pub(super) async fn shared_instance_install_preview_from_version(
let external_files = version
.external_files
.iter()
- .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
+ .filter(|file| !matches!(file.file_type.as_str(), CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE))
.map(|file| SharedInstanceExternalFilePreview {
file_name: file.file_name.clone(),
file_type: file.file_type.clone(),
@@ -529,6 +529,22 @@ pub(super) async fn shared_instance_install_data(
.into());
}
+ let remote = match get_remote_instance_access(shared_instance_id, state)
+ .await?
+ {
+ SharedInstanceRemoteResponse::Available(remote) => remote,
+ SharedInstanceRemoteResponse::Unavailable(reason) => {
+ return Err(shared_instance_unavailable_error(reason));
+ }
+ };
+ let (manager_id, server_manager_name, server_manager_icon_url) =
+ if remote.linked_server.is_some() {
+ (None, Some(remote.name), remote.icon.clone())
+ } else {
+ (manager_id, server_manager_name, server_manager_icon_url)
+ };
+ let instance_icon_url = remote.icon.or(instance_icon_url);
+
let name = shared_instance_name(name);
let linked_user_id = linked_modrinth_user_id(state).await?;
let modpack = shared_instance_install_modpack(&version, state).await?;
@@ -540,7 +556,29 @@ pub(super) async fn shared_instance_install_data(
.filter(|id| Some(id.as_str()) != modpack_version_id)
.collect();
+ let mut config_count = 0;
+ let mut config_size = 0_u64;
+ for file in &version.external_files {
+ if file.file_type == CONFIG_FILE_TYPE {
+ config_count += 1;
+ config_size = config_size.saturating_add(
+ file.file_size
+ .and_then(|size| u64::try_from(size).ok())
+ .unwrap_or(u64::MAX),
+ );
+ }
+ }
+ if config_count > MAX_CONFIG_BUNDLE_ENTRIES
+ || config_size > MAX_CONFIG_BUNDLE_TOTAL_SIZE
+ {
+ return Err(crate::ErrorKind::InputError(
+ "Shared instance config files exceed the size or file count limit"
+ .to_string(),
+ ).into());
+ }
+
Ok(SharedInstanceInstallData {
+ linked_server: remote.linked_server,
shared_instance_id: shared_instance_id.to_string(),
manager_id,
server_manager_name,
diff --git a/packages/app-lib/src/api/instance/shared/mod.rs b/packages/app-lib/src/api/instance/shared/mod.rs
index 4fdce76f1c8..730736cddf0 100644
--- a/packages/app-lib/src/api/instance/shared/mod.rs
+++ b/packages/app-lib/src/api/instance/shared/mod.rs
@@ -32,6 +32,7 @@ use std::io::Read;
pub(crate) const CONFIG_BUNDLE_FILE_NAME: &str = "configs.zip";
pub(crate) const CONFIG_BUNDLE_FILE_TYPE: &str = "configs";
+pub(crate) const CONFIG_FILE_TYPE: &str = "config";
pub(crate) const CONFIG_SYNC_ENABLED: bool = true;
pub(crate) const CONFIG_DIRECTORY: &str = "config";
pub(crate) const MAX_CONFIG_BUNDLE_ENTRIES: usize = 4096;
diff --git a/packages/app-lib/src/api/instance/shared/publish.rs b/packages/app-lib/src/api/instance/shared/publish.rs
index e49218df069..1bf7c444c53 100644
--- a/packages/app-lib/src/api/instance/shared/publish.rs
+++ b/packages/app-lib/src/api/instance/shared/publish.rs
@@ -195,7 +195,7 @@ pub(super) async fn remote_publish_content(
version
.external_files
.iter()
- .filter(|file| file.file_type != CONFIG_BUNDLE_FILE_TYPE)
+ .filter(|file| !matches!(file.file_type.as_str(), CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE))
.map(|file| {
shared_external_file_key(&file.file_type, &file.file_name)
})
diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs
index 0dfa24941b4..9baca6e6a6e 100644
--- a/packages/app-lib/src/api/pack/install_from.rs
+++ b/packages/app-lib/src/api/pack/install_from.rs
@@ -630,12 +630,16 @@ pub async fn set_instance_information(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
- name: Some(
- description
- .override_title
- .clone()
- .unwrap_or_else(|| backup_name.to_string()),
- ),
+ name: if matches!(&link, Some(InstanceLink::SharedInstance { .. })) {
+ None
+ } else {
+ Some(
+ description
+ .override_title
+ .clone()
+ .unwrap_or_else(|| backup_name.to_string()),
+ )
+ },
icon_path: description
.icon
.as_ref()
diff --git a/packages/app-lib/src/install/model.rs b/packages/app-lib/src/install/model.rs
index c80a1878220..a2123dfe98c 100644
--- a/packages/app-lib/src/install/model.rs
+++ b/packages/app-lib/src/install/model.rs
@@ -212,8 +212,16 @@ pub struct InstallPostInstallEdit {
pub link: Option,
}
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct SharedInstanceLinkedServer {
+ pub domain: String,
+ pub region: String,
+}
+
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallData {
+ #[serde(default)]
+ pub linked_server: Option,
pub shared_instance_id: String,
pub manager_id: Option,
#[serde(default)]
diff --git a/packages/app-lib/src/install/shared_instance.rs b/packages/app-lib/src/install/shared_instance.rs
index 102f1500b76..43b9dbcb917 100644
--- a/packages/app-lib/src/install/shared_instance.rs
+++ b/packages/app-lib/src/install/shared_instance.rs
@@ -9,6 +9,7 @@ use super::runner::{
};
use crate::api::instance::{
CONFIG_BUNDLE_FILE_TYPE, CONFIG_DIRECTORY, CONFIG_FILE_EXTENSIONS,
+ CONFIG_FILE_TYPE, MAX_CONFIG_BUNDLE_FILE_SIZE,
CONFIG_SYNC_ENABLED, MAX_CONFIG_BUNDLE_ENTRIES,
read_bounded_config_bundle_entry,
};
@@ -56,6 +57,15 @@ pub(super) async fn finalize_shared_instance_attachment(
data: &SharedInstanceInstallData,
state: &State,
) -> crate::Result<()> {
+ if let Some(server) = &data.linked_server {
+ crate::api::worlds::ensure_managed_server_in_instance(
+ instance_id,
+ data.server_manager_name.clone()
+ .unwrap_or_else(|| data.name.clone()),
+ server.domain.clone(),
+ )
+ .await?;
+ }
crate::state::attach_shared_instance(
instance_id,
SharedInstanceAttachmentInput {
@@ -117,6 +127,7 @@ struct DesiredSharedInstanceContent {
DesiredSharedInstanceExternalFile,
>,
config_bundle: Option,
+ config_files: Vec,
}
struct SharedInstanceProjectUpdate {
@@ -134,6 +145,7 @@ struct SharedInstanceApplyPlan {
external_updates: Vec,
external_additions: Vec,
config_bundle: Option,
+ config_files: Vec,
}
impl SharedInstanceApplyPlan {
@@ -169,6 +181,7 @@ impl SharedInstanceApplyPlan {
project_removals,
external_removals,
config_bundle: desired.config_bundle,
+ config_files: desired.config_files,
..Default::default()
};
@@ -203,7 +216,8 @@ impl SharedInstanceApplyPlan {
+ self.project_additions.len()
+ self.external_updates.len()
+ self.external_additions.len()
- + usize::from(self.config_bundle.is_some())) as u64
+ + usize::from(self.config_bundle.is_some())
+ + self.config_files.len()) as u64
}
}
@@ -367,10 +381,10 @@ pub(super) async fn apply_shared_instance_update(
.await?;
}
- if let Some(config_bundle) = plan.config_bundle {
+ for config_file in plan.config_bundle.into_iter().chain(plan.config_files) {
install_shared_instance_external_file(
instance_id,
- &config_bundle,
+ &config_file,
state,
)
.await?;
@@ -518,6 +532,12 @@ async fn desired_shared_instance_content(
}
for file in &data.external_files {
+ if file.file_type == CONFIG_FILE_TYPE {
+ if CONFIG_SYNC_ENABLED {
+ content.config_files.push(file.clone());
+ }
+ continue;
+ }
if file.file_type == CONFIG_BUNDLE_FILE_TYPE {
if CONFIG_SYNC_ENABLED {
content.config_bundle = Some(file.clone());
@@ -788,11 +808,27 @@ async fn install_shared_instance_external_file(
file: &SharedInstanceExternalFileData,
state: &State,
) -> crate::Result<()> {
- if file.file_type == CONFIG_BUNDLE_FILE_TYPE && !CONFIG_SYNC_ENABLED {
+ if matches!(
+ file.file_type.as_str(),
+ CONFIG_BUNDLE_FILE_TYPE | CONFIG_FILE_TYPE
+ ) && !CONFIG_SYNC_ENABLED {
return Ok(());
}
- validate_shared_instance_external_file_name(&file.file_name)?;
+ if file.file_type == CONFIG_FILE_TYPE {
+ crate::state::content_store::validate_relative(&file.file_name)?;
+ if file.file_name.split('/').any(|part| part.starts_with('.'))
+ || !is_supported_config_file(std::path::Path::new(&file.file_name))
+ || file.file_size > MAX_CONFIG_BUNDLE_FILE_SIZE
+ {
+ return Err(crate::ErrorKind::InputError(format!(
+ "Shared instance config file {} is unsupported or too large",
+ file.file_name
+ )).into());
+ }
+ } else {
+ validate_shared_instance_external_file_name(&file.file_name)?;
+ }
if file.file_size > MAX_SHARED_INSTANCE_EXTERNAL_FILE_SIZE {
return Err(crate::ErrorKind::InputError(format!(
@@ -863,6 +899,18 @@ async fn install_shared_instance_external_file(
}
let bytes = bytes::Bytes::from(bytes);
+ if file.file_type == CONFIG_FILE_TYPE {
+ let path = crate::api::instance::validate_instance_file_write(
+ instance_id,
+ &file.file_name,
+ ).await?;
+ if let Some(parent) = path.parent() {
+ crate::util::io::create_dir_all(parent).await?;
+ }
+ crate::util::io::write(path, bytes).await?;
+ return Ok(());
+ }
+
if file.file_type == CONFIG_BUNDLE_FILE_TYPE {
return install_shared_instance_config_bundle(
instance_id,
diff --git a/packages/ui/src/components/base/BaseTerminal.vue b/packages/ui/src/components/base/BaseTerminal.vue
index b169c1528fb..3a3399efcf2 100644
--- a/packages/ui/src/components/base/BaseTerminal.vue
+++ b/packages/ui/src/components/base/BaseTerminal.vue
@@ -1,9 +1,14 @@
-
-
+
+
(),
{
scrollback: Infinity,
+ minLogHeight: undefined,
showInput: false,
disableInput: false,
disableInputTooltip: undefined,
diff --git a/packages/ui/src/components/base/IntlFormatted.vue b/packages/ui/src/components/base/IntlFormatted.vue
index 7edebb2064c..a6a944bcb27 100644
--- a/packages/ui/src/components/base/IntlFormatted.vue
+++ b/packages/ui/src/components/base/IntlFormatted.vue
@@ -55,7 +55,9 @@ const formattedParts = computed(() => {
for (const slotName of slotNames) {
const normalizedName = slotName.startsWith('~') ? slotName.slice(1) : slotName
- slotHandlers[normalizedName] = (chunks) => {
+ let handlerName = `intlSlot${Object.keys(slotHandlers).length}`
+ while (Object.prototype.hasOwnProperty.call(props.values ?? {}, handlerName)) handlerName += '_'
+ slotHandlers[handlerName] = (chunks) => {
const slot = slots[slotName]
if (slot) {
const nodes = slot({
@@ -69,10 +71,12 @@ const formattedParts = computed(() => {
return markRaw(chunks) as VNode[]
}
- msg = msg.replace(
- new RegExp(`\\{${normalizedName}\\}`, 'g'),
- `<${normalizedName}>${normalizedName}>`,
- )
+ msg = msg
+ .replaceAll(`<${normalizedName}>`, `<${handlerName}>`)
+ .replaceAll(`${normalizedName}>`, `${handlerName}>`)
+ if (!Object.prototype.hasOwnProperty.call(props.values ?? {}, normalizedName)) {
+ msg = msg.replaceAll(`{${normalizedName}}`, `<${handlerName}>${handlerName}>`)
+ }
}
try {
diff --git a/packages/ui/src/components/servers/ServerConfigFilePicker.vue b/packages/ui/src/components/servers/ServerConfigFilePicker.vue
new file mode 100644
index 00000000000..d0061c15727
--- /dev/null
+++ b/packages/ui/src/components/servers/ServerConfigFilePicker.vue
@@ -0,0 +1,146 @@
+
+
+
+
+ {{ formatMessage(messages.title) }}
+
+
+
+ {{ formatMessage(messages.retry) }}
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
index 08a45cfab50..be5e722a73b 100644
--- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
+++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
@@ -10,6 +10,7 @@ import StackedAdmonitions, {
type StackedAdmonitionItem,
} from '#ui/components/base/StackedAdmonitions.vue'
import InstallingBanner from '#ui/components/servers/InstallingBanner.vue'
+import ServerConfigFilePicker from '#ui/components/servers/ServerConfigFilePicker.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerPermissions } from '#ui/composables/server-permissions'
@@ -42,7 +43,9 @@ const needsShareUpdate = computed(() =>
)
const shareActions = useIsMutating({ mutationKey: ['servers', 'share-action', ctx.serverId] })
const sharePreviews = useIsFetching({ queryKey: ['servers', 'share-diff', ctx.serverId] })
-const sharePending = computed(() => shareActions.value > 0 || sharePreviews.value > 0)
+const resolvingConfigs = ref(false)
+const sharePending = computed(() => resolvingConfigs.value || shareActions.value > 0 || sharePreviews.value > 0)
+const configPicker = ref
>()
const diffModal = ref>()
const previewOpen = ref(false)
@@ -57,12 +60,11 @@ const previewQuery = useQuery({
})
const pushMutation = useMutation({
mutationKey: ['servers', 'share-action', ctx.serverId],
- mutationFn: async (worldId: string) => {
- await client.archon.content_v1.share(ctx.serverId, worldId)
+ mutationFn: async ({ worldId, configPaths }: { worldId: string; configPaths: string[] }) => {
+ await client.archon.content_v1.share(ctx.serverId, worldId, configPaths)
await queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', ctx.serverId] })
await queryClient.invalidateQueries({ queryKey: ['servers', 'share-diff', ctx.serverId, worldId] })
},
- onError: (error) => handleError(error),
})
async function reviewShareUpdate() {
@@ -80,10 +82,22 @@ async function reviewShareUpdate() {
}
}
-function pushShareUpdate() {
+async function pushShareUpdate() {
if (!ctx.worldId.value || !canSetup.value || sharePending.value || ctx.busyReasons.value.length) return
- previewOpen.value = false
- pushMutation.mutate(ctx.worldId.value)
+ const worldId = ctx.worldId.value
+ const userId = auth.user.value?.id
+ resolvingConfigs.value = true
+ try {
+ const configPaths = await configPicker.value?.resolvePaths() ?? []
+ if (!previewOpen.value || ctx.worldId.value !== worldId || auth.user.value?.id !== userId || !canSetup.value || ctx.busyReasons.value.length) return
+ await pushMutation.mutateAsync({ worldId, configPaths })
+ previewOpen.value = false
+ } catch (error) {
+ handleError(error)
+ if (previewOpen.value && ctx.worldId.value === worldId && auth.user.value?.id === userId) diffModal.value?.show()
+ } finally {
+ resolvingConfigs.value = false
+ }
}
watch([ctx.worldId, () => auth.user.value?.id], () => {
@@ -554,5 +568,9 @@ function onInstallationDismiss() {
:removed-label="formatMessage(messages.removed)"
@confirm="pushShareUpdate"
@cancel="previewOpen = false"
- />
+ >
+
+
+
+
diff --git a/packages/ui/src/composables/hosting-intercom.ts b/packages/ui/src/composables/hosting-intercom.ts
index 6c986399891..ceb8d15a492 100644
--- a/packages/ui/src/composables/hosting-intercom.ts
+++ b/packages/ui/src/composables/hosting-intercom.ts
@@ -45,6 +45,9 @@ const LAUNCHER_SELECTOR =
const RIGHT_VAR = '--modrinth-hosting-intercom-right'
const BOTTOM_VAR = '--modrinth-hosting-intercom-bottom'
const POINTER_EVENTS_VAR = '--modrinth-hosting-intercom-pointer-events'
+const LAUNCHER_OPACITY_VAR = '--modrinth-hosting-intercom-launcher-opacity'
+const LAUNCHER_VISIBILITY_VAR = '--modrinth-hosting-intercom-launcher-visibility'
+const LAUNCHER_POINTER_EVENTS_VAR = '--modrinth-hosting-intercom-launcher-pointer-events'
function sanitizePixels(value: number | undefined, fallback = DEFAULT_PADDING) {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
@@ -75,7 +78,12 @@ iframe[name='intercom-messenger-frame'] {
iframe[name='intercom-launcher-frame'] {
right: var(${RIGHT_VAR}, ${DEFAULT_PADDING}px) !important;
bottom: var(${BOTTOM_VAR}, ${DEFAULT_PADDING}px) !important;
+ opacity: var(${LAUNCHER_OPACITY_VAR}, 1) !important;
+ visibility: var(${LAUNCHER_VISIBILITY_VAR}, visible) !important;
+ pointer-events: var(${LAUNCHER_POINTER_EVENTS_VAR}, auto) !important;
transition:
+ opacity 0.15s ease-out,
+ visibility 0.15s,
right 0.12s ease-out,
bottom 0.12s ease-out !important;
}
@@ -95,6 +103,8 @@ export function useHostingIntercom(options: UseHostingIntercomOptions) {
const { stackCount } = useModalStack()
const horizontalPaddingRequests = new Map()
const verticalClearanceRequests = new Map()
+ const hiddenRequests = new Set()
+ const launcherHidden = ref(false)
const requestedHorizontalPadding = ref(null)
const requestedVerticalClearance = ref(null)
const launcherWidth = ref(DEFAULT_LAUNCHER_WIDTH)
@@ -105,7 +115,7 @@ export function useHostingIntercom(options: UseHostingIntercomOptions) {
let syncAfterBoot = false
let stopSync: (() => void) | null = null
let stopPositionSync: (() => void) | null = null
- let stopModalSync: (() => void) | null = null
+ let stopVisibilitySync: (() => void) | null = null
let launcherObserver: ResizeObserver | null = null
let documentObserver: MutationObserver | null = null
let observedLauncher: Element | null = null
@@ -145,6 +155,15 @@ export function useHostingIntercom(options: UseHostingIntercomOptions) {
POINTER_EVENTS_VAR,
stackCount.value > 0 ? 'none' : 'auto',
)
+ document.documentElement.style.setProperty(LAUNCHER_OPACITY_VAR, launcherHidden.value ? '0' : '1')
+ document.documentElement.style.setProperty(
+ LAUNCHER_VISIBILITY_VAR,
+ launcherHidden.value ? 'hidden' : 'visible',
+ )
+ document.documentElement.style.setProperty(
+ LAUNCHER_POINTER_EVENTS_VAR,
+ launcherHidden.value || stackCount.value > 0 ? 'none' : 'auto',
+ )
if (updateSdk && booted) {
updateIntercom({
@@ -180,6 +199,9 @@ export function useHostingIntercom(options: UseHostingIntercomOptions) {
document.documentElement.style.removeProperty(RIGHT_VAR)
document.documentElement.style.removeProperty(BOTTOM_VAR)
document.documentElement.style.removeProperty(POINTER_EVENTS_VAR)
+ document.documentElement.style.removeProperty(LAUNCHER_OPACITY_VAR)
+ document.documentElement.style.removeProperty(LAUNCHER_VISIBILITY_VAR)
+ document.documentElement.style.removeProperty(LAUNCHER_POINTER_EVENTS_VAR)
}
function stop() {
@@ -262,13 +284,13 @@ export function useHostingIntercom(options: UseHostingIntercomOptions) {
immediate: true,
})
stopPositionSync = watch([horizontalPadding, verticalPadding], () => applyPosition(true))
- stopModalSync = watch(stackCount, () => applyPosition())
+ stopVisibilitySync = watch([stackCount, launcherHidden], () => applyPosition())
})
onBeforeUnmount(() => {
stopSync?.()
stopPositionSync?.()
- stopModalSync?.()
+ stopVisibilitySync?.()
launcherObserver?.disconnect()
documentObserver?.disconnect()
stop()
@@ -279,6 +301,11 @@ export function useHostingIntercom(options: UseHostingIntercomOptions) {
intercomBubble: {
width: launcherWidth,
horizontalPadding,
+ requestHidden: (id: symbol, hidden: boolean) => {
+ if (hidden) hiddenRequests.add(id)
+ else hiddenRequests.delete(id)
+ launcherHidden.value = hiddenRequests.size > 0
+ },
requestHorizontalPadding: (id: symbol, value: number | null) =>
requestFromMap(horizontalPaddingRequests, requestedHorizontalPadding, id, value),
requestVerticalClearance: (id: symbol, value: number | null) =>
diff --git a/packages/ui/src/layouts/index.ts b/packages/ui/src/layouts/index.ts
index d828ac37554..64257c0a9de 100644
--- a/packages/ui/src/layouts/index.ts
+++ b/packages/ui/src/layouts/index.ts
@@ -4,6 +4,7 @@ export * from './shared/console'
export * from './shared/content-tab'
export * from './shared/files-tab'
export * from './shared/installation-settings'
+export * from './shared/invited-players'
export * from './shared/server-settings'
export * from './shared/sharing-settings'
export * from './shared/user-profile'
diff --git a/packages/ui/src/layouts/shared/console/layout.vue b/packages/ui/src/layouts/shared/console/layout.vue
index d6a04d753a3..aed88fda113 100644
--- a/packages/ui/src/layouts/shared/console/layout.vue
+++ b/packages/ui/src/layouts/shared/console/layout.vue
@@ -1,12 +1,14 @@
-
+
()
+
const ctx = injectConsoleManager()
const client = injectModrinthClient()
const modalBehavior = injectModalBehavior()
diff --git a/packages/ui/src/layouts/shared/invited-players/composables/use-invited-players-table.ts b/packages/ui/src/layouts/shared/invited-players/composables/use-invited-players-table.ts
new file mode 100644
index 00000000000..863cbf903ed
--- /dev/null
+++ b/packages/ui/src/layouts/shared/invited-players/composables/use-invited-players-table.ts
@@ -0,0 +1,77 @@
+import { computed, ref, type Ref, watch } from 'vue'
+
+import type { SortDirection } from '#ui/components/base/Table.vue'
+
+import {
+ invitedPlayerMethodLabels as methodLabels,
+ type InvitedPlayerMethod,
+ type InvitedPlayerRow,
+} from '../types'
+
+type MethodFilter = InvitedPlayerMethod | 'all'
+
+export function useInvitedPlayersTable(
+ rows: Ref
,
+ formatRelativeTime: (date: Date) => string,
+) {
+ const search = ref('')
+ const methodFilter = ref('all')
+ const sortColumn = ref('joined')
+ const sortDirection = ref('desc')
+ const methodFilterOptions: Array<{ id: InvitedPlayerMethod; label: string }> = [
+ { id: 'direct', label: methodLabels.direct },
+ { id: 'link', label: methodLabels.link },
+ ]
+ const hasMultipleMethods = computed(() => new Set(rows.value.map((row) => row.method)).size > 1)
+ const filteredRows = computed(() => {
+ const query = search.value.trim().toLowerCase()
+ return rows.value.filter((row) => {
+ if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
+ if (!query) return true
+ return [
+ row.username,
+ row.lastPlayedAt ? formatRelativeTime(row.lastPlayedAt) : 'Never',
+ row.pending ? 'Pending' : row.joinedAt ? formatRelativeTime(row.joinedAt) : '',
+ methodLabels[row.method],
+ ].some((value) => value.toLowerCase().includes(query))
+ })
+ })
+ const sortedRows = computed(() => [...filteredRows.value].sort(compareRows))
+
+ function compareRows(a: InvitedPlayerRow, b: InvitedPlayerRow) {
+ let compared: number
+ if (sortColumn.value === 'username') compared = a.username.localeCompare(b.username)
+ else if (sortColumn.value === 'lastPlayed')
+ compared =
+ (a.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY) -
+ (b.lastPlayedAt?.getTime() ?? Number.NEGATIVE_INFINITY)
+ else if (sortColumn.value === 'method')
+ compared = methodLabels[a.method].localeCompare(methodLabels[b.method])
+ else
+ compared =
+ (a.pending ? Number.MAX_SAFE_INTEGER : (a.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) -
+ (b.pending
+ ? Number.MAX_SAFE_INTEGER
+ : (b.joinedAt?.getTime() ?? Number.NEGATIVE_INFINITY)) ||
+ a.username.localeCompare(b.username)
+ return sortDirection.value === 'asc' ? compared : -compared
+ }
+ function toggleMethodFilter(filter: InvitedPlayerMethod) {
+ methodFilter.value = methodFilter.value === filter ? 'all' : filter
+ }
+
+ watch(hasMultipleMethods, (multiple) => {
+ if (!multiple) methodFilter.value = 'all'
+ })
+
+ return {
+ search,
+ methodFilter,
+ sortColumn,
+ sortDirection,
+ methodFilterOptions,
+ hasMultipleMethods,
+ sortedRows,
+ toggleMethodFilter,
+ }
+}
diff --git a/packages/ui/src/layouts/shared/invited-players/index.ts b/packages/ui/src/layouts/shared/invited-players/index.ts
new file mode 100644
index 00000000000..e8eadeeefe3
--- /dev/null
+++ b/packages/ui/src/layouts/shared/invited-players/index.ts
@@ -0,0 +1,2 @@
+export { default as InvitedPlayersTableLayout } from './layout.vue'
+export * from './types'
diff --git a/packages/ui/src/layouts/shared/invited-players/layout.vue b/packages/ui/src/layouts/shared/invited-players/layout.vue
new file mode 100644
index 00000000000..f1c23b8a8cf
--- /dev/null
+++ b/packages/ui/src/layouts/shared/invited-players/layout.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+ {{
+ formatMessage(rows.length === 0 ? messages.noUsersJoined : messages.noUsersMatchFilters)
+ }}
+
+
+
+
+
+
+ {{ row.username }}
+
+
+
+
+ {{
+ formatRelativeTime(row.lastPlayedAt)
+ }}
+ Never
+
+
+ Pending
+ {{
+ formatRelativeTime(row.joinedAt)
+ }}
+
+
+
+
+
+ {{ methodLabels[row.method] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/invited-players/types.ts b/packages/ui/src/layouts/shared/invited-players/types.ts
new file mode 100644
index 00000000000..8e559575f6e
--- /dev/null
+++ b/packages/ui/src/layouts/shared/invited-players/types.ts
@@ -0,0 +1,16 @@
+export type InvitedPlayerMethod = 'direct' | 'link'
+
+export type InvitedPlayerRow = {
+ id: string
+ username: string
+ avatarUrl?: string
+ lastPlayedAt: Date | null
+ joinedAt: Date | null
+ method: InvitedPlayerMethod
+ pending?: boolean
+}
+
+export const invitedPlayerMethodLabels: Record = {
+ direct: 'Direct invite',
+ link: 'Share link',
+}
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
deleted file mode 100644
index 2ba3def2038..00000000000
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue
+++ /dev/null
@@ -1,201 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- No users match your filters.
-
-
-
-
-
-
-
- {{ formatPlayerDate(row.lastPlayedAt) }}
-
- Never
-
-
-
- Pending
-
-
- {{ formatPlayerDate(row.joinedAt) }}
-
-
-
-
-
-
- {{ methodLabel(row.method) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
index 862f46b3b9d..ad58837a112 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/play.vue
@@ -1,5 +1,5 @@
-
+
{{ formatMessage(messages.retry) }}
-
+
+
+
+
+
+ {{ formatMessage(messages.pushUpdate) }}
+
+
+
+
+
{{ formatMessage(messages.refreshingPreview) }}
{{ formatMessage(messages.retry) }}
@@ -60,20 +77,22 @@
import type { Archon } from '@modrinth/api-client'
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { useIsMutating, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
-import { useStorage } from '@vueuse/core'
-import { computed, nextTick, ref, watch } from 'vue'
+import { useIntersectionObserver, useStorage } from '@vueuse/core'
+import { computed, nextTick, onScopeDispose, ref, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import { Button } from '#ui/components/base/buttons'
import ConfirmModal from '#ui/components/modal/ConfirmModal.vue'
+import ServerConfigFilePicker from '#ui/components/servers/ServerConfigFilePicker.vue'
import { type InviteLinkSettings, type InvitePlayersInvitePayload, InvitePlayersModal, type InvitePlayersUser } from '#ui/components/sharing'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import ContentDiffModal from '#ui/layouts/shared/installation-settings/components/ContentDiffModal.vue'
+import InvitedPlayersTableLayout from '#ui/layouts/shared/invited-players/layout.vue'
import { getHostingServerAddress, injectAuth, injectModrinthClient, injectModrinthServerContext, injectNotificationManager, type ServerPlayTarget } from '#ui/providers'
+import { injectPageContext } from '#ui/providers/page-context'
import ServerPlayCard from './ServerPlayCard.vue'
-import ServerPlayersTable from './ServerPlayersTable.vue'
import { resolveServerShareDiff } from './share-diff'
import type { ServerPlayerRow } from './types'
import { useServerPlayers } from './use-server-players'
@@ -85,6 +104,15 @@ const props = defineProps<{
siteUrl: string
}>()
const { formatMessage } = useVIntl()
+const pageContext = injectPageContext(null)
+const pageBottom = ref(null)
+const intercomHiddenRequestId = Symbol('server-play-bottom')
+useIntersectionObserver(pageBottom, ([entry]) => {
+ pageContext?.intercomBubble?.requestHidden?.(intercomHiddenRequestId, entry?.isIntersecting ?? false)
+})
+onScopeDispose(() => {
+ pageContext?.intercomBubble?.requestHidden?.(intercomHiddenRequestId, false)
+})
const { handleError } = injectNotificationManager()
const client = injectModrinthClient()
const auth = injectAuth()
@@ -97,6 +125,8 @@ const needsUpdate = computed(() => world.value?.content?.shared_instance_needs_u
const players = useServerPlayers(sharedInstanceId, canSetup)
const invitePlayersModal = ref>()
const diffModal = ref>()
+const configPicker = ref>()
+const resolvingConfigs = ref(false)
const removeModal = ref>()
const playerToRemove = ref()
const previewOpen = ref(false)
@@ -116,13 +146,13 @@ const previewQuery = useQuery({
})
const actionMutation = useMutation({
mutationKey: ['servers', 'share-action', serverId],
- mutationFn: async ({ action, targetWorldId, userId }: { action: Action; targetWorldId: string; userId: string | undefined }) => {
+ mutationFn: async ({ action, targetWorldId, userId, configPaths }: { action: Action; targetWorldId: string; userId: string | undefined; configPaths: string[] }) => {
if (busyReasons.value.length) throw new Error(formatMessage(messages.busy))
if (auth.user.value?.id !== userId) return
const sameContext = () => worldId.value === targetWorldId && auth.user.value?.id === userId
let id = serverFull.value?.worlds.find((world) => world.id === targetWorldId)?.content?.shared_instance_id
- if (canSetup.value) {
- const shared = await client.archon.content_v1.share(serverId, targetWorldId)
+ if (canSetup.value && (action !== 'play' || !id || needsUpdate.value || configPaths.length > 0)) {
+ const shared = await client.archon.content_v1.share(serverId, targetWorldId, configPaths)
id = shared.shared_instance_id
queryClient.setQueryData(['servers', 'v1', 'detail', serverId], (current) => current ? {
...current,
@@ -130,7 +160,9 @@ const actionMutation = useMutation({
...world, content: { ...world.content, shared_instance_id: shared.shared_instance_id },
} : world),
} : current)
- await queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] })
+ const refresh = queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] })
+ if (action === 'play') void refresh
+ else await refresh
} else if (action === 'invite' || action === 'push') {
throw new Error(formatMessage(messages.permission))
}
@@ -158,15 +190,27 @@ const actionMutation = useMutation({
})
const pendingAction = computed(() => actionMutation.isPending.value ? actionMutation.variables.value?.action : undefined)
const shareActions = useIsMutating({ mutationKey: ['servers', 'share-action', serverId] })
-const actionsLocked = computed(() => shareActions.value > 0 || previewQuery.isFetching.value || players.linkMutation.isPending.value || busyReasons.value.length > 0)
-function perform(action: Action, reviewed = false) {
+const actionsLocked = computed(() => resolvingConfigs.value || shareActions.value > 0 || previewQuery.isFetching.value || players.linkMutation.isPending.value || busyReasons.value.length > 0)
+async function perform(action: Action, reviewed = false) {
if (!worldId.value || actionsLocked.value) return
if (action === 'play' && !reviewed && canSetup.value && needsUpdate.value && preferences.value.reviewChangesBeforePlaying) {
void showPreview(true)
return
}
- previewOpen.value = false
- actionMutation.mutate({ action, targetWorldId: worldId.value, userId: auth.user.value?.id })
+ const targetWorldId = worldId.value
+ const userId = auth.user.value?.id
+ resolvingConfigs.value = true
+ try {
+ const configPaths = reviewed ? await configPicker.value?.resolvePaths() ?? [] : []
+ if (worldId.value !== targetWorldId || auth.user.value?.id !== userId || (reviewed && !previewOpen.value)) return
+ previewOpen.value = false
+ actionMutation.mutate({ action, targetWorldId, userId, configPaths })
+ } catch (error) {
+ handleError(error)
+ if (previewOpen.value && worldId.value === targetWorldId && auth.user.value?.id === userId) diffModal.value?.show()
+ } finally {
+ resolvingConfigs.value = false
+ }
}
async function showPreview(playAfter = false) {
if (actionsLocked.value) return
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
index 63f2dbb0550..d69cf7cad06 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/types.ts
@@ -1,11 +1,4 @@
-export type ServerPlayerMethod = 'direct' | 'link'
-
-export type ServerPlayerRow = {
- id: string
- username: string
- avatarUrl?: string
- lastPlayedAt: Date | null
- joinedAt: Date | null
- method: ServerPlayerMethod
- pending?: boolean
-}
+export type {
+ InvitedPlayerMethod as ServerPlayerMethod,
+ InvitedPlayerRow as ServerPlayerRow,
+} from '#ui/layouts/shared/invited-players/types'
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue b/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
index f5608bb195f..95b607e48ce 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
@@ -1,13 +1,10 @@
-
@@ -59,11 +56,11 @@ import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers
const props = withDefaults(
defineProps<{
showAdvancedDebugInfo?: boolean
- containedConsole?: boolean
+ fillConsole?: boolean
}>(),
{
showAdvancedDebugInfo: false,
- containedConsole: false,
+ fillConsole: false,
},
)
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
index 89b4840ba6c..b5e3f693905 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -107,8 +107,8 @@
}"
:class="[
'server-panel-' + revealState,
- containedLayout
- ? 'h-full min-h-0 overflow-hidden pb-6'
+ fillLayout
+ ? 'flex-1 pb-6'
: constrainWidth
? 'min-h-[100svh] max-w-[1280px] pb-16'
: 'min-h-[calc(100svh-100px)] pb-6',
@@ -118,7 +118,7 @@
@@ -227,13 +227,12 @@
-
+
@@ -241,7 +240,7 @@
@@ -414,7 +413,7 @@ const props = withDefaults(
type: 'mod' | 'plugin' | 'datapack'
}) => void | Promise
constrainWidth?: boolean
- layoutMode?: 'page' | 'contained'
+ layoutMode?: 'page' | 'fill'
}>(),
{
showCopyIdAction: false,
@@ -468,7 +467,7 @@ const DISABLE_LOADING_ANIM = true
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const constrainWidth = computed(() => props.constrainWidth)
-const containedLayout = computed(() => props.layoutMode === 'contained')
+const fillLayout = computed(() => props.layoutMode === 'fill')
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const queryClient = useQueryClient()
const route = useRoute()
diff --git a/packages/ui/src/providers/page-context.ts b/packages/ui/src/providers/page-context.ts
index 99585948aaa..b8eb0d444a1 100644
--- a/packages/ui/src/providers/page-context.ts
+++ b/packages/ui/src/providers/page-context.ts
@@ -16,6 +16,7 @@ export interface PageContext {
horizontalPadding: Ref | ComputedRef
requestHorizontalPadding?: (id: symbol, padding: number | null) => void
requestVerticalClearance: (id: symbol, clearance: number | null) => void
+ requestHidden?: (id: symbol, hidden: boolean) => void
}
featureFlags?: {
serverRamAsBytesAlwaysOn?: Ref