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/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 899e526d95..a4170149bf 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,7 @@ const fileNameRef = ref(null) const isDisabled = computed(() => props.disabled || props.installing) const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled) +const isEnabledForDisabled = computed(() => !props.enabledFor?.server && !props.enabledFor?.player) const clientWarningMessage = computed(() => { switch (props.clientWarning) { @@ -154,16 +165,21 @@ const installTooltip = computed(() => { role="row" class="flex items-center justify-between" :class="{ - 'h-[74px] gap-4 px-3': !inline, + 'h-[74px] gap-4 px-3': !inline && !enabledFor, + 'h-[72px] gap-4 px-3': !inline && enabledFor, 'gap-3': inline, 'opacity-50 grayscale': disabled && !installing, - 'opacity-50': installing, + 'opacity-50': installing || (enabledFor && isEnabledForDisabled && !disabled), }" >
{
-
{
+ +