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 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/app-frontend/src/pages/hosting/manage/Play.vue b/apps/app-frontend/src/pages/hosting/manage/Play.vue new file mode 100644 index 0000000000..170357365d --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Play.vue @@ -0,0 +1,7 @@ + + + + + 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/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/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..0c23d78a03 --- /dev/null +++ b/apps/frontend/src/pages/hosting/manage/[id]/play.vue @@ -0,0 +1,13 @@ + + + + + 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 @@ + + + + + {{ formatMessage(messages[side]) }} + + + + + + + + + + + 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/[id]/play/ServerPlayCard.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue new file mode 100644 index 0000000000..3fa2fb5bc0 --- /dev/null +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayCard.vue @@ -0,0 +1,168 @@ + + + + + + {{ formatMessage(messages.playWithAppTitle) }} + + + {{ formatMessage(messages.playWithAppDescription) }} + + + + + + {{ formatMessage(messages.playServerButton) }} + + + + {{ formatMessage(messages.invitePlayersButton) }} + + + + + + + + + {{ formatMessage(messages.differentLauncherTitle) }} + + + {{ formatMessage(messages.differentLauncherDescription) }} + + + + + + + {{ formatMessage(messages.downloadModpackButton) }} + + + + {{ formatMessage(messages.downloadModpackButton) }} + + + + + + + + + + {{ 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..1426bdb611 --- /dev/null +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/play/ServerPlayersTable.vue @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + No users match your filters. + + + + + + {{ row.username }} + + + + + {{ 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/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 @@
+ {{ formatMessage(messages.playWithAppDescription) }} +
+ {{ formatMessage(messages.differentLauncherDescription) }} +