From b9d623a23071f3f293a31428f02dcfd8fb1c4d3e Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Mon, 3 Aug 2026 07:56:03 +0100
Subject: [PATCH 1/6] 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 df46e24f6b..3ec4546cab 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -22,6 +22,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 e182d1386f..2a80591868 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 0000000000..3728c9ceb0
--- /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 0c10b07133..073c549296 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 a5327694cb..33a29ac053 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -26,6 +26,11 @@ export default new createRouter({
name: 'ServerManage',
component: Hosting.Index,
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 1165ef04cc..e7300aec27 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 5f6d5245f4..9e218386ab 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 0000000000..b4866d98f7
--- /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 0000000000..b515971992
--- /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 0000000000..f235922655
--- /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 0000000000..0c9fd04c2d
--- /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 0000000000..63f2dbb055
--- /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 8dd42e6503..bda8e9e6ff 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -318,6 +318,7 @@ import {
LinkIcon,
LoaderCircleIcon,
LockIcon,
+ PlayIcon,
MoreVerticalIcon,
ServerIcon as ServerAssetIcon,
SettingsIcon,
@@ -395,6 +396,7 @@ const props = withDefaults(
showCopyIdAction?: boolean
showAdvancedDebugInfo?: boolean
showUptime?: boolean
+ showPlayTab?: boolean
additionalTabs?: Tab[]
stripePublishableKey?: string
siteUrl?: string
@@ -419,6 +421,7 @@ const props = withDefaults(
showCopyIdAction: false,
showAdvancedDebugInfo: false,
showUptime: true,
+ showPlayTab: false,
additionalTabs: () => [],
stripePublishableKey: undefined,
siteUrl: undefined,
@@ -776,6 +779,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 59ba8d7109..c4ddd4de57 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 96e9c2880fa567852577b194976d7c2b8ea9a55a Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Fri, 4 Sep 2026 15:30:06 +0100
Subject: [PATCH 2/6] 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 b515971992..3fa2fb5bc0 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 f235922655..56730841e2 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 f739ece3ff4b944e21e9dabbc95767db44fa3c91 Mon Sep 17 00:00:00 2001
From: "Calum H."
Date: Fri, 4 Sep 2026 15:33:02 +0100
Subject: [PATCH 3/6] 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 | 61 ++-
.../components/ContentCardItemIcon.vue | 91 ++++
.../components/ContentCardTable.vue | 71 ++-
.../components/ContentEnabledFor.vue | 112 +++++
.../managed-content-modal/index.vue | 99 +++-
.../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 | 10 -
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, 1073 insertions(+), 507 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 c088dfb550..d410b4fd08 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 10ef710b71..d7fe88af3b 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 fa0c951d6f..71aedc9bd1 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 beea64e832..d198ea1bf0 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
@@ -1073,6 +1104,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 9f5f4f8b16..02d32927a8 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 4ffa62d33c..ddc0153102 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 30541a240e..400cc15070 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 e80b2e503e..71c973c402 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 {
listContainer,
@@ -152,7 +155,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) {
@@ -163,8 +166,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(
@@ -180,7 +183,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 e707dc318c..352ad28c19 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
@@ -28,11 +28,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()
@@ -77,6 +82,8 @@ interface Props {
hideDelete?: boolean
hideActions?: boolean
inline?: boolean
+ enabledFor?: ContentEnabledForState
+ embeddedIcon?: ContentCardEmbeddedIcon
}
const props = withDefaults(defineProps(), {
@@ -104,12 +111,15 @@ const props = withDefaults(defineProps(), {
hideDelete: false,
hideActions: false,
inline: false,
+ enabledFor: undefined,
+ embeddedIcon: undefined,
})
const selected = defineModel('selected')
const emit = defineEmits<{
'update:enabled': [value: boolean]
+ 'update:enabled-for': [side: ContentSide, value: boolean]
select: [value: boolean, event?: MouseEvent]
delete: [event: MouseEvent]
update: []
@@ -128,6 +138,11 @@ const fileNameRef = ref(null)
const isDisabled = computed(() => props.disabled || props.installing)
const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
+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) {
@@ -152,18 +167,25 @@ const installTooltip = computed(() => {
{
+
+ emit('update:enabled-for', side, value)"
+ />
+
+
@@ -335,7 +365,8 @@ const installTooltip = computed(() => {
@@ -399,11 +430,7 @@ 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 a9e32f1d8d..6e1c50a1ec 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 47562c1eda..c5b0782a67 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'
@@ -42,6 +48,7 @@ interface Props {
sourceName?: string
sourceIconUrl?: string
enableToggle?: boolean
+ enableEnabledFor?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => ButtonMenuOption[]
@@ -54,6 +61,7 @@ const props = withDefaults(defineProps(), {
sourceName: undefined,
sourceIconUrl: undefined,
enableToggle: false,
+ enableEnabledFor: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
@@ -63,6 +71,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: []
@@ -109,6 +118,18 @@ const messages = defineMessages({
id: 'instances.managed-content-modal.download-file',
defaultMessage: 'Download File',
},
+ 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 {
@@ -262,6 +283,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: item.version ?? {
id: item.id,
version_number: contentVersionLabel(item),
@@ -282,16 +304,24 @@ 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 } : {}),
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
? [
@@ -329,7 +359,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) {
@@ -372,6 +404,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])
@@ -474,8 +513,12 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
@@ -539,13 +582,16 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
@@ -561,15 +607,38 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
formatMessage(commonMessages.projectLabel)
}}
+
+ {{
+ formatMessage(messages.enabledFor)
+ }}
+
+
{{
formatMessage(commonMessages.versionLabel)
}}
-
+
{{
formatMessage(commonMessages.actionsLabel)
}}
@@ -580,15 +649,17 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="props.enableToggle"
- :show-item-actions="hasExternalSlicerUrls"
+ :show-enabled-for-column="props.enableEnabledFor"
+ :show-item-actions="props.enableEnabledFor || hasExternalSlicerUrls"
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),
@@ -191,32 +200,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]
@@ -424,14 +437,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)
@@ -505,7 +526,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))
@@ -617,6 +641,7 @@ async function disableItemsWithoutWarning(items: ContentItem[]) {
await ctx.bulkDisableItems(items)
return
}
+ if (!ctx.toggleEnabled) return
for (const item of items) {
const id = getItemId(item)
@@ -730,7 +755,7 @@ async function confirmDisable() {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(itemsToDisable)
} else {
- await ctx.toggleEnabled(item)
+ await ctx.toggleEnabled?.(item)
}
} finally {
unmarkChanging(id)
@@ -744,7 +769,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
@@ -782,7 +807,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() {
@@ -805,6 +831,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([])
@@ -1220,7 +1254,9 @@ const confirmUnlinkModal = ref>()
class="mt-2"
:items="tableItems"
: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 10397a9c35..e0ae0d3479 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 6154b33ca1..407a691e8a 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
@@ -74,6 +90,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 51e3c4ca3f..72b316f0c7 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 c32ba61b1e..47e52c3dad 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 e7300aec27..1165ef04cc 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 9e218386ab..5f6d5245f4 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 b4866d98f7..0c23d78a03 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 bda8e9e6ff..1a199fe292 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -396,7 +396,6 @@ const props = withDefaults(
showCopyIdAction?: boolean
showAdvancedDebugInfo?: boolean
showUptime?: boolean
- showPlayTab?: boolean
additionalTabs?: Tab[]
stripePublishableKey?: string
siteUrl?: string
@@ -421,7 +420,6 @@ const props = withDefaults(
showCopyIdAction: false,
showAdvancedDebugInfo: false,
showUptime: true,
- showPlayTab: false,
additionalTabs: () => [],
stripePublishableKey: undefined,
siteUrl: undefined,
@@ -779,16 +777,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 8c732c5cf1722a7c46b97b8fa79034a040809730 Mon Sep 17 00:00:00 2001
From: "Calum H. (IMB11)"
Date: Fri, 4 Sep 2026 16:03:22 +0100
Subject: [PATCH 5/6] 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 56730841e2..1426bdb611 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 6/6] 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 0000000000..80a156db14
--- /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