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 00fb3c7da4..62bebb4531 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 @@ -1063,6 +1094,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 cd4101b096..474817a1d2 100644 --- a/packages/ui/src/composables/server-panel-sync.ts +++ b/packages/ui/src/composables/server-panel-sync.ts @@ -233,17 +233,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 { @@ -297,7 +328,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(() => {