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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@

-
- - - - - - - - - - -
+ + + + + + + + +
@@ -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(() => {