feat: clean up browse shared layout logic + introduce queuing (#6030)
* feat: clean up edge case behaviour and add queued to install logic * fix: remove version choice modal * feat: queued flow * feat: standardize headers in app on proj pages * fix: clear btn * feat: installing floating popup * fix: lint * fix: onboarding/reset logic change for modpacks * qa: big ol qa * fix: lint * fix: lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -49,6 +49,20 @@ export class ArchonContentV1Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/install-many */
|
||||
public async addAddons(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
addons: Archon.Content.v1.AddAddonRequest[],
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/install-many`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: { addons } satisfies Archon.Content.v1.AddAddonsRequest,
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/delete */
|
||||
public async deleteAddon(
|
||||
serverId: string,
|
||||
|
||||
@@ -51,6 +51,10 @@ export namespace Archon {
|
||||
kind?: AddonKind
|
||||
}
|
||||
|
||||
export type AddAddonsRequest = {
|
||||
addons: AddAddonRequest[]
|
||||
}
|
||||
|
||||
export type RemoveAddonRequest = {
|
||||
kind: AddonKind
|
||||
filename: string
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
:class="{
|
||||
'bg-brand border-button-border text-brand-inverted': modelValue,
|
||||
'bg-surface-2 border-surface-5 text-primary': !modelValue,
|
||||
'checkbox-shadow group-active:scale-95': disabled,
|
||||
'checkbox-shadow group-active:scale-95': !disabled,
|
||||
}"
|
||||
>
|
||||
<MinusIcon v-if="indeterminate" aria-hidden="true" stroke-width="3" />
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
shown: boolean
|
||||
ariaLabel?: string
|
||||
belowModal?: boolean
|
||||
hideWhenModalOpen?: boolean
|
||||
}>()
|
||||
|
||||
const INTERCOM_BUBBLE_GAP = 8
|
||||
@@ -18,7 +19,7 @@ const compact = ref(false)
|
||||
|
||||
const { stackCount } = useModalStack()
|
||||
const pageContext = injectPageContext(null)
|
||||
const shown = computed(() => props.shown)
|
||||
const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCount.value === 0))
|
||||
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
|
||||
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
|
||||
const leftOffset = computed(
|
||||
@@ -82,11 +83,11 @@ function updateIntercomBubbleClearance() {
|
||||
)
|
||||
}
|
||||
|
||||
function updateBodyState(shown = props.shown) {
|
||||
function updateBodyState(isShown = shown.value) {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.body.classList.toggle('floating-action-bar-shown', shown)
|
||||
if (!shown) {
|
||||
document.body.classList.toggle('floating-action-bar-shown', isShown)
|
||||
if (!isShown) {
|
||||
clearIntercomBubbleClearance()
|
||||
}
|
||||
}
|
||||
@@ -123,10 +124,10 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.shown,
|
||||
async (shown) => {
|
||||
shown,
|
||||
async (isShown) => {
|
||||
await nextTick()
|
||||
updateBodyState(shown)
|
||||
updateBodyState(isShown)
|
||||
scheduleIntercomBubbleClearanceUpdate()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -175,7 +176,7 @@ onUnmounted(() => {
|
||||
ref="toolbarEl"
|
||||
role="toolbar"
|
||||
:aria-label="ariaLabel"
|
||||
class="relative overflow-clip flex items-center gap-2 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-4 py-3 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="{ 'bar-compact': compact }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -7,18 +7,13 @@
|
||||
:waiting="isWaiting"
|
||||
@dismiss="emit('dismiss')"
|
||||
>
|
||||
<template #icon>
|
||||
<slot v-if="!contentError" name="icon">
|
||||
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
|
||||
</slot>
|
||||
</template>
|
||||
<template #header>
|
||||
{{ contentError ? 'Installation failed' : "We're preparing your server" }}
|
||||
{{ headerLabel }}
|
||||
</template>
|
||||
<template v-if="contentError">
|
||||
{{ errorLabel }}
|
||||
</template>
|
||||
<template v-else-if="progress">{{ phaseLabel }}</template>
|
||||
<template v-else-if="effectivePhase">{{ phaseLabel }}</template>
|
||||
<div v-else class="ticker-container">
|
||||
<div class="ticker-content">
|
||||
<div
|
||||
@@ -35,7 +30,7 @@
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button class="!border" type="button" @click="emit('retry')">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
Retry
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -44,9 +39,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
@@ -62,6 +59,7 @@ export interface ContentError {
|
||||
|
||||
const props = defineProps<{
|
||||
progress?: SyncProgress | null
|
||||
fallbackPhase?: SyncProgress['phase'] | null
|
||||
contentError?: ContentError | null
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
@@ -71,44 +69,123 @@ const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
errorHeader: {
|
||||
id: 'servers.installing-banner.error.header',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
preparingHeader: {
|
||||
id: 'servers.installing-banner.preparing.header',
|
||||
defaultMessage: "We're preparing your server",
|
||||
},
|
||||
invalidLoaderVersionError: {
|
||||
id: 'servers.installing-banner.error.invalid-loader-version',
|
||||
defaultMessage:
|
||||
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
|
||||
},
|
||||
unsupportedLoaderVersionError: {
|
||||
id: 'servers.installing-banner.error.unsupported-loader-version',
|
||||
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
|
||||
},
|
||||
internalPlatformError: {
|
||||
id: 'servers.installing-banner.error.internal-platform',
|
||||
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
|
||||
},
|
||||
noPrimaryFileError: {
|
||||
id: 'servers.installing-banner.error.no-primary-file',
|
||||
defaultMessage:
|
||||
'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
|
||||
},
|
||||
modpackInstallFailedError: {
|
||||
id: 'servers.installing-banner.error.modpack-install-failed',
|
||||
defaultMessage: 'The modpack could not be installed. It may be corrupted or incompatible.',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'servers.installing-banner.error.unknown',
|
||||
defaultMessage: 'An unexpected error occurred during installation.',
|
||||
},
|
||||
installingPlatform: {
|
||||
id: 'servers.installing-banner.phase.installing-platform',
|
||||
defaultMessage: 'Installing platform...',
|
||||
},
|
||||
installingModpack: {
|
||||
id: 'servers.installing-banner.phase.installing-modpack',
|
||||
defaultMessage: 'Installing modpack...',
|
||||
},
|
||||
installingAddons: {
|
||||
id: 'servers.installing-banner.phase.installing-addons',
|
||||
defaultMessage: 'Installing addons...',
|
||||
},
|
||||
tickerOrganizingFiles: {
|
||||
id: 'servers.installing-banner.ticker.organizing-files',
|
||||
defaultMessage: 'Organizing files...',
|
||||
},
|
||||
tickerDownloadingMods: {
|
||||
id: 'servers.installing-banner.ticker.downloading-mods',
|
||||
defaultMessage: 'Downloading mods...',
|
||||
},
|
||||
tickerConfiguringServer: {
|
||||
id: 'servers.installing-banner.ticker.configuring-server',
|
||||
defaultMessage: 'Configuring server...',
|
||||
},
|
||||
tickerSettingUpEnvironment: {
|
||||
id: 'servers.installing-banner.ticker.setting-up-environment',
|
||||
defaultMessage: 'Setting up environment...',
|
||||
},
|
||||
tickerAddingJava: {
|
||||
id: 'servers.installing-banner.ticker.adding-java',
|
||||
defaultMessage: 'Adding Java...',
|
||||
},
|
||||
})
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
const desc = props.contentError?.description?.toLowerCase()
|
||||
const step = props.contentError?.step
|
||||
|
||||
if (step === 'modloader') {
|
||||
if (desc === 'the specified version may be incorrect') {
|
||||
return 'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.'
|
||||
return formatMessage(messages.invalidLoaderVersionError)
|
||||
}
|
||||
if (desc === 'this version is not yet supported') {
|
||||
return 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.'
|
||||
return formatMessage(messages.unsupportedLoaderVersionError)
|
||||
}
|
||||
if (desc === 'internal error') {
|
||||
return 'An internal error occurred while installing the platform. Please try again.'
|
||||
return formatMessage(messages.internalPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'modpack') {
|
||||
if (desc?.includes('no primary file')) {
|
||||
return 'This modpack version does not include a downloadable file. It may have been packaged incorrectly.'
|
||||
return formatMessage(messages.noPrimaryFileError)
|
||||
}
|
||||
if (desc?.includes('failed to install')) {
|
||||
return 'The modpack could not be installed. It may be corrupted or incompatible.'
|
||||
return formatMessage(messages.modpackInstallFailedError)
|
||||
}
|
||||
}
|
||||
|
||||
return props.contentError?.description ?? 'An unexpected error occurred during installation.'
|
||||
return props.contentError?.description ?? formatMessage(messages.unknownError)
|
||||
})
|
||||
|
||||
const effectivePhase = computed(() => props.progress?.phase ?? props.fallbackPhase ?? null)
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (props.contentError) return formatMessage(messages.errorHeader)
|
||||
if (effectivePhase.value === 'Addons') return formatMessage(commonMessages.installingContentLabel)
|
||||
return formatMessage(messages.preparingHeader)
|
||||
})
|
||||
|
||||
const phaseLabel = computed(() => {
|
||||
switch (props.progress?.phase) {
|
||||
switch (effectivePhase.value) {
|
||||
case 'InstallingLoader':
|
||||
return 'Installing platform...'
|
||||
return formatMessage(messages.installingPlatform)
|
||||
case 'InstallingPack':
|
||||
return 'Installing modpack...'
|
||||
return formatMessage(messages.installingModpack)
|
||||
case 'Addons':
|
||||
return 'Installing addons...'
|
||||
return formatMessage(messages.installingAddons)
|
||||
default:
|
||||
return 'Installing...'
|
||||
return formatMessage(commonMessages.installingLabel)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,13 +199,13 @@ const isWaiting = computed(() => {
|
||||
return !props.progress || props.progress.percent <= 0
|
||||
})
|
||||
|
||||
const tickerMessages = [
|
||||
'Organizing files...',
|
||||
'Downloading mods...',
|
||||
'Configuring server...',
|
||||
'Setting up environment...',
|
||||
'Adding Java...',
|
||||
]
|
||||
const tickerMessages = computed(() => [
|
||||
formatMessage(messages.tickerOrganizingFiles),
|
||||
formatMessage(messages.tickerDownloadingMods),
|
||||
formatMessage(messages.tickerConfiguringServer),
|
||||
formatMessage(messages.tickerSettingUpEnvironment),
|
||||
formatMessage(messages.tickerAddingJava),
|
||||
])
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
@@ -136,7 +213,7 @@ let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % tickerMessages.length
|
||||
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import StackedAdmonitions, {
|
||||
type StackedAdmonitionItem,
|
||||
} from '#ui/components/base/StackedAdmonitions.vue'
|
||||
import { ServerIcon } from '#ui/components/servers/icons'
|
||||
import InstallingBanner, {
|
||||
type ContentError,
|
||||
type SyncProgress,
|
||||
@@ -23,7 +22,6 @@ import UploadAdmonition from './UploadAdmonition.vue'
|
||||
const props = defineProps<{
|
||||
syncProgress?: SyncProgress | null
|
||||
contentError?: ContentError | null
|
||||
serverImage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -59,7 +57,13 @@ const isOnContentTab = computed(() => route.path.includes('/content'))
|
||||
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
||||
|
||||
const bannerCoversInstalling = computed(
|
||||
() => ctx.server.value?.status === 'installing' || ctx.isSyncingContent.value,
|
||||
() =>
|
||||
ctx.server.value?.status === 'installing' ||
|
||||
ctx.isSyncingContent.value ||
|
||||
ctx.busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
|
||||
),
|
||||
)
|
||||
|
||||
function isBackupReason(id: string) {
|
||||
@@ -165,8 +169,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
|
||||
|
||||
const showInstallingBanner = computed(() => {
|
||||
if (!ctx.server.value) return false
|
||||
const installing =
|
||||
ctx.server.value.status === 'installing' || ctx.isSyncingContent.value || !!props.contentError
|
||||
const installing = bannerCoversInstalling.value || !!props.contentError
|
||||
if (!installing) return false
|
||||
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
|
||||
return false
|
||||
@@ -366,15 +369,12 @@ function onContentErrorDismiss() {
|
||||
<InstallingBanner
|
||||
v-if="item.kind === 'installing'"
|
||||
:progress="syncProgress"
|
||||
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
|
||||
:content-error="contentError"
|
||||
:dismissible="dismissible && !!contentError"
|
||||
@dismiss="onContentErrorDismiss"
|
||||
@retry="emit('content-retry')"
|
||||
>
|
||||
<template #icon>
|
||||
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
|
||||
</template>
|
||||
</InstallingBanner>
|
||||
/>
|
||||
<UploadAdmonition v-else-if="item.kind === 'upload'" />
|
||||
<FileOperationAdmonition
|
||||
v-else-if="item.kind === 'fs-op'"
|
||||
|
||||
@@ -12,10 +12,20 @@ export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
||||
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
|
||||
const { serverId, server, powerState, isSyncingContent, busyReasons } =
|
||||
injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const isInstalling = computed(() => server.value.status === 'installing')
|
||||
const isInstalling = computed(
|
||||
() =>
|
||||
server.value.status === 'installing' ||
|
||||
isSyncingContent.value ||
|
||||
busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' ||
|
||||
r.reason.id === 'servers.busy.syncing-content',
|
||||
),
|
||||
)
|
||||
const isRunning = computed(() => powerState.value === 'running')
|
||||
const isStopping = computed(() => powerState.value === 'stopping')
|
||||
const isStarting = computed(() => powerState.value === 'starting')
|
||||
|
||||
@@ -4,13 +4,21 @@ import { computed, ref, watch, watchEffect } from 'vue'
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
bufferSize?: number
|
||||
initialItemCount?: number
|
||||
enabled?: Ref<boolean>
|
||||
onNearEnd?: () => void
|
||||
nearEndThreshold?: number
|
||||
}
|
||||
|
||||
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
|
||||
const { itemHeight, bufferSize = 5, enabled, onNearEnd, nearEndThreshold = 0.2 } = options
|
||||
const {
|
||||
itemHeight,
|
||||
bufferSize = 5,
|
||||
initialItemCount = 20,
|
||||
enabled,
|
||||
onNearEnd,
|
||||
nearEndThreshold = 0.2,
|
||||
} = options
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const scrollContainer = ref<HTMLElement | Window | null>(null)
|
||||
@@ -68,7 +76,9 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
return { start: 0, end: items.value.length }
|
||||
}
|
||||
|
||||
if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 }
|
||||
if (!listContainer.value || !scrollContainer.value) {
|
||||
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
|
||||
}
|
||||
|
||||
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset.value)
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
:shown="shown"
|
||||
:aria-label="formatMessage(messages.ariaLabel)"
|
||||
hide-when-modal-open
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-0.5">
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
class="relative h-8 shrink-0"
|
||||
:style="{ width: `${iconStackWidth}px` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(project, index) in visibleProjects"
|
||||
:key="project.id"
|
||||
v-tooltip="project.name"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
|
||||
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleProjects.length - index }"
|
||||
>
|
||||
<Avatar
|
||||
:src="project.iconUrl"
|
||||
:alt="project.name"
|
||||
:tint-by="project.id"
|
||||
size="100%"
|
||||
no-shadow
|
||||
class="selected-project-avatar"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="overflowCount > 0"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
|
||||
:style="{ left: `${visibleProjects.length * iconStackOffset}px`, zIndex: 0 }"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ selectedCountText }}
|
||||
</span>
|
||||
<div class="mx-0.5 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
class="!text-primary"
|
||||
:disabled="isInstallingSelected"
|
||||
@click="clearSelected"
|
||||
>
|
||||
<span>{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto shrink-0">
|
||||
<ButtonStyled color="green">
|
||||
<button type="button" :disabled="isInstallingSelected" @click="installSelected">
|
||||
<PlusIcon />
|
||||
{{ installButtonText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectBrowseManager } from '../providers/browse-manager'
|
||||
import type { BrowseInstallContext } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
ariaLabel: {
|
||||
id: 'browse.selected-projects-floating-bar.aria-label',
|
||||
defaultMessage: 'Selected projects',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'browse.selected-projects-floating-bar.selected-count',
|
||||
defaultMessage: '{count, plural, one {# project selected} other {# projects selected}}',
|
||||
},
|
||||
installButton: {
|
||||
id: 'browse.selected-projects-floating-bar.install',
|
||||
defaultMessage: 'Install {count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
installContext?: BrowseInstallContext | null
|
||||
}>()
|
||||
|
||||
const ctx = injectBrowseManager(null)
|
||||
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
|
||||
const selectedProjects = computed(() => installContext.value?.selectedProjects ?? [])
|
||||
const selectedCount = computed(() => selectedProjects.value.length)
|
||||
const iconStackOffset = 24
|
||||
const isInstallingSelected = computed(() => installContext.value?.isInstallingSelected ?? false)
|
||||
const shown = computed(() => selectedCount.value > 0 && !isInstallingSelected.value)
|
||||
const visibleProjects = computed(() => selectedProjects.value.slice(0, 3))
|
||||
const overflowCount = computed(() => Math.max(0, selectedCount.value - 3))
|
||||
const iconStackWidth = computed(() => {
|
||||
if (selectedCount.value === 0) return 0
|
||||
return (
|
||||
32 + (visibleProjects.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
|
||||
)
|
||||
})
|
||||
const selectedCountText = computed(() =>
|
||||
formatMessage(messages.selectedCount, { count: selectedCount.value }),
|
||||
)
|
||||
const installButtonText = computed(() =>
|
||||
formatMessage(messages.installButton, { count: selectedCount.value }),
|
||||
)
|
||||
|
||||
function clearSelected() {
|
||||
if (isInstallingSelected.value) return
|
||||
void (installContext.value?.clearSelected ?? installContext.value?.clearQueued)?.()
|
||||
}
|
||||
|
||||
function installSelected() {
|
||||
if (isInstallingSelected.value) return
|
||||
void installContext.value?.installSelected?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.selected-project-avatar) {
|
||||
background-color: var(--color-button-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="560px">
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ formatMessage(messages.admonitionBody, { count }) }}
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="resolve('cancel')">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="installing" @click="resolve('discard')">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.discardButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button :disabled="installing" @click="resolve('install')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'browse.selected-projects-leave-modal.header',
|
||||
defaultMessage: 'Selected projects not installed yet',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'browse.selected-projects-leave-modal.admonition-header',
|
||||
defaultMessage: 'Selected projects not installed yet',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'browse.selected-projects-leave-modal.admonition-body',
|
||||
defaultMessage:
|
||||
'You have selected {count, plural, one {# project} other {# projects}} to install. Install them now or go back without installing them.',
|
||||
},
|
||||
discardButton: {
|
||||
id: 'browse.selected-projects-leave-modal.discard',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
count: number
|
||||
installing?: boolean
|
||||
}>()
|
||||
|
||||
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: SelectedProjectsLeaveResult) => void) | null = null
|
||||
|
||||
function prompt(): Promise<SelectedProjectsLeaveResult> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function resolve(result: SelectedProjectsLeaveResult) {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(result)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
@@ -1 +1,2 @@
|
||||
export * from './install-logic'
|
||||
export * from './use-browse-search'
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { FilterValue } from '#ui/utils/search'
|
||||
|
||||
export type BrowseInstallContentType = 'modpack' | 'mod' | 'plugin' | 'datapack'
|
||||
export type BrowseInstallAddonContentType = Exclude<BrowseInstallContentType, 'modpack'>
|
||||
|
||||
/**
|
||||
* Indicates why a concrete version was selected.
|
||||
*
|
||||
* `filtered` means the current browse filters resolved the version.
|
||||
* `target` means filter resolution failed or matched the target exactly, so the server/instance target won.
|
||||
*/
|
||||
export type BrowseInstallPlanSource = 'filtered' | 'target'
|
||||
|
||||
/**
|
||||
* Version constraints used during install resolution.
|
||||
*
|
||||
* Empty arrays and blank values are normalized away, so missing properties mean "do not constrain".
|
||||
*/
|
||||
export interface BrowseInstallPreferences {
|
||||
gameVersions?: string[]
|
||||
loaders?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server or instance metadata that should be used as the fallback compatibility target.
|
||||
*/
|
||||
export interface BrowseInstallTarget {
|
||||
gameVersion?: string | null
|
||||
loader?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal project shape needed by shared install resolution.
|
||||
*/
|
||||
export interface BrowseInstallProject {
|
||||
project_id: string
|
||||
latest_version?: string | null
|
||||
version_id?: string | null
|
||||
title?: string
|
||||
name?: string
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully resolved install work item.
|
||||
*
|
||||
* This is intentionally concrete so queued installs can be flushed later without re-resolving
|
||||
* against filters that may have changed since the user clicked install.
|
||||
*/
|
||||
export interface BrowseInstallPlan<TProject extends BrowseInstallProject = BrowseInstallProject> {
|
||||
project: TProject
|
||||
projectId: string
|
||||
versionId: string
|
||||
versionName?: string
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
contentType: BrowseInstallContentType
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Small adapter around caller-owned queue state.
|
||||
*
|
||||
* Callers keep their own reactive storage; shared logic only replaces the whole map.
|
||||
*/
|
||||
export interface BrowseInstallQueue<TProject extends BrowseInstallProject = BrowseInstallProject> {
|
||||
get: () => Map<string, BrowseInstallPlan<TProject>>
|
||||
set: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inputs for deriving selected install preferences.
|
||||
*
|
||||
* Provided filters come from a target context, and overridden filter types are ignored so user
|
||||
* choices can replace the target-provided constraints.
|
||||
*/
|
||||
export interface SelectedInstallPreferencesOptions {
|
||||
contentType: string
|
||||
selectedFilters?: readonly FilterValue[]
|
||||
providedFilters?: readonly FilterValue[]
|
||||
overriddenProvidedFilterTypes?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs for resolving one concrete install plan.
|
||||
*
|
||||
* Version fetching is injected so this module stays platform-agnostic and can be used by both web
|
||||
* and app frontends.
|
||||
*/
|
||||
export interface ResolveInstallPlanOptions<
|
||||
TProject extends BrowseInstallProject,
|
||||
> extends SelectedInstallPreferencesOptions {
|
||||
project: TProject
|
||||
contentType: BrowseInstallContentType
|
||||
targetPreferences?: BrowseInstallPreferences
|
||||
getProjectVersions: (projectId: string) => Promise<Labrinth.Versions.v2.Version[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Install request wrapper around plan resolution.
|
||||
*
|
||||
* Queue mode stores the resolved plan; immediate mode passes it to the caller's install handler.
|
||||
*/
|
||||
export interface RequestInstallOptions<
|
||||
TProject extends BrowseInstallProject,
|
||||
> extends ResolveInstallPlanOptions<TProject> {
|
||||
mode: 'queue' | 'immediate'
|
||||
queue?: BrowseInstallQueue<TProject>
|
||||
install?: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs for committing queued plans without re-running version matching.
|
||||
*/
|
||||
export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject> {
|
||||
queue: BrowseInstallQueue<TProject>
|
||||
install: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
|
||||
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
|
||||
onProgress?: (
|
||||
completed: number,
|
||||
total: number,
|
||||
plan: BrowseInstallPlan<TProject>,
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a queue flush. Failed plans are also written back to the queue.
|
||||
*/
|
||||
export interface FlushInstallQueueResult<TProject extends BrowseInstallProject> {
|
||||
ok: boolean
|
||||
successfulPlans: BrowseInstallPlan<TProject>[]
|
||||
failedPlans: Map<string, BrowseInstallPlan<TProject>>
|
||||
}
|
||||
|
||||
interface InstallCandidate {
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a project/content type to the browse filter keys that represent its loader.
|
||||
*/
|
||||
export function getLoaderFilterTypes(contentType: string) {
|
||||
if (contentType === 'mod') return ['mod_loader']
|
||||
if (contentType === 'plugin') return ['plugin_loader', 'plugin_platform']
|
||||
if (contentType === 'modpack') return ['modpack_loader']
|
||||
if (contentType === 'shader') return ['shader_loader']
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges user-selected filters with target-provided filters for install decisions.
|
||||
*
|
||||
* User filters win per filter type, provided filters are dropped when overridden, and negative
|
||||
* filters are excluded because they are browse-only constraints.
|
||||
*/
|
||||
export function getEffectiveInstallFilters({
|
||||
selectedFilters = [],
|
||||
providedFilters = [],
|
||||
overriddenProvidedFilterTypes = [],
|
||||
}: Omit<SelectedInstallPreferencesOptions, 'contentType'>) {
|
||||
const effectiveProvidedFilters = providedFilters.filter(
|
||||
(providedFilter) => !overriddenProvidedFilterTypes.includes(providedFilter.type),
|
||||
)
|
||||
const userFilters = selectedFilters.filter(
|
||||
(userFilter) =>
|
||||
!effectiveProvidedFilters.some((providedFilter) => providedFilter.type === userFilter.type),
|
||||
)
|
||||
|
||||
return [...userFilters, ...effectiveProvidedFilters].filter((filter) => !filter.negative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts effective browse filters into install preferences for a specific content type.
|
||||
*/
|
||||
export function getInstallPreferencesFromFilters(
|
||||
contentType: string,
|
||||
filters: readonly FilterValue[],
|
||||
): BrowseInstallPreferences {
|
||||
const loaderFilterTypes = getLoaderFilterTypes(contentType)
|
||||
const gameVersions = uniqueDefined(
|
||||
filters.filter((filter) => filter.type === 'game_version').map((filter) => filter.option),
|
||||
)
|
||||
const loaders = uniqueDefined(
|
||||
filters
|
||||
.filter((filter) => loaderFilterTypes.includes(filter.type))
|
||||
.map((filter) => filter.option),
|
||||
)
|
||||
|
||||
return normalizeInstallPreferences({
|
||||
gameVersions: gameVersions.length > 0 ? gameVersions : undefined,
|
||||
loaders: loaders.length > 0 ? loaders : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the preferences represented by the current browse selection plus active provided filters.
|
||||
*/
|
||||
export function getSelectedInstallPreferences(
|
||||
options: SelectedInstallPreferencesOptions,
|
||||
): BrowseInstallPreferences {
|
||||
return getInstallPreferencesFromFilters(options.contentType, getEffectiveInstallFilters(options))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts server/instance metadata into fallback install preferences.
|
||||
*/
|
||||
export function getTargetInstallPreferences(
|
||||
target: BrowseInstallTarget,
|
||||
contentType?: string,
|
||||
): BrowseInstallPreferences {
|
||||
const gameVersion = target.gameVersion?.trim()
|
||||
const loader = target.loader?.trim()
|
||||
const shouldUseTargetRuntime = contentType !== 'modpack'
|
||||
|
||||
return normalizeInstallPreferences({
|
||||
gameVersions: gameVersion && shouldUseTargetRuntime ? [gameVersion] : undefined,
|
||||
loaders: loader && shouldUseTargetRuntime ? [loader] : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes loader identifiers so API and UI aliases compare consistently.
|
||||
*/
|
||||
export function normalizeLoaderAlias(loader: string) {
|
||||
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns aliases that should be considered mutually compatible for install matching.
|
||||
*/
|
||||
export function getCompatibleLoaderAliases(loader: string) {
|
||||
const normalized = normalizeLoaderAlias(loader)
|
||||
if (!normalized) return new Set<string>()
|
||||
if (['paper', 'purpur', 'spigot', 'bukkit'].includes(normalized)) {
|
||||
return new Set(['paper', 'purpur', 'spigot', 'bukkit'])
|
||||
}
|
||||
if (normalized === 'neoforge' || normalized === 'neo') {
|
||||
return new Set(['neoforge', 'neo'])
|
||||
}
|
||||
return new Set([normalized])
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether selected filters conflict with the target constraints.
|
||||
*/
|
||||
export function preferencesDiffer(
|
||||
selected: BrowseInstallPreferences,
|
||||
target: BrowseInstallPreferences,
|
||||
) {
|
||||
return (
|
||||
preferencesConflict(selected.gameVersions, target.gameVersions) ||
|
||||
loaderPreferencesConflict(selected.loaders, target.loaders)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills missing selected preferences from the target.
|
||||
*
|
||||
* This preserves the user's explicit filter choices while still constraining unconstrained axes to
|
||||
* the server/instance target.
|
||||
*/
|
||||
export function mergeInstallPreferences(
|
||||
selected: BrowseInstallPreferences,
|
||||
target: BrowseInstallPreferences,
|
||||
): BrowseInstallPreferences {
|
||||
return normalizeInstallPreferences({
|
||||
gameVersions: selected.gameVersions?.length ? selected.gameVersions : target.gameVersions,
|
||||
loaders: selected.loaders?.length ? selected.loaders : target.loaders,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the newest version matching the given preferences.
|
||||
*/
|
||||
export function getLatestMatchingInstallVersion(
|
||||
versions: readonly Labrinth.Versions.v2.Version[],
|
||||
preferences: BrowseInstallPreferences,
|
||||
contentType: string,
|
||||
) {
|
||||
return [...versions]
|
||||
.filter((version) => versionMatchesPreferences(version, preferences, contentType))
|
||||
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the concrete version to install.
|
||||
*
|
||||
* The resolver tries the filtered plan first, with target values filling any missing axes. If that
|
||||
* cannot resolve and differs from the target, it falls back to the target-only plan.
|
||||
*/
|
||||
export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
|
||||
options: ResolveInstallPlanOptions<TProject>,
|
||||
): Promise<BrowseInstallPlan<TProject>> {
|
||||
const projectId = options.project.project_id
|
||||
if (!projectId) {
|
||||
throw new Error('No project is available for install.')
|
||||
}
|
||||
|
||||
const selectedPreferences = getSelectedInstallPreferences(options)
|
||||
const targetPreferences = normalizeInstallPreferences(options.targetPreferences)
|
||||
const candidates = getInstallCandidates(selectedPreferences, targetPreferences)
|
||||
const versions = await options.getProjectVersions(projectId)
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const version = getLatestMatchingInstallVersion(
|
||||
versions,
|
||||
candidate.preferences,
|
||||
options.contentType,
|
||||
)
|
||||
|
||||
if (version) {
|
||||
const fileName =
|
||||
version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename
|
||||
return {
|
||||
project: options.project,
|
||||
projectId,
|
||||
versionId: version.id,
|
||||
versionName: version.name,
|
||||
versionNumber: version.version_number,
|
||||
fileName,
|
||||
contentType: options.contentType,
|
||||
preferences: candidate.preferences,
|
||||
source: candidate.source,
|
||||
}
|
||||
}
|
||||
|
||||
lastError = createNoCompatibleVersionError(options.contentType, candidate.preferences)
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('No version found for this project.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and either queues or immediately installs a project.
|
||||
*
|
||||
* Queue replacement is keyed by project ID, so clicking install again after changing filters
|
||||
* replaces the previously resolved plan.
|
||||
*/
|
||||
export async function requestInstall<TProject extends BrowseInstallProject>(
|
||||
options: RequestInstallOptions<TProject>,
|
||||
) {
|
||||
const plan = await resolveInstallPlan(options)
|
||||
|
||||
if (options.mode === 'queue') {
|
||||
if (!options.queue) {
|
||||
throw new Error('No install queue is available.')
|
||||
}
|
||||
|
||||
const nextPlans = new Map(options.queue.get())
|
||||
nextPlans.set(plan.projectId, plan)
|
||||
options.queue.set(nextPlans)
|
||||
return plan
|
||||
}
|
||||
|
||||
await options.install?.(plan)
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits queued install plans exactly as stored.
|
||||
*
|
||||
* Successful plans are removed; failed plans remain in the queue for retry or user action.
|
||||
*/
|
||||
export async function flushInstallQueue<TProject extends BrowseInstallProject>({
|
||||
queue,
|
||||
install,
|
||||
onError,
|
||||
onProgress,
|
||||
}: FlushInstallQueueOptions<TProject>): Promise<FlushInstallQueueResult<TProject>> {
|
||||
const queuedPlans = Array.from(queue.get().values())
|
||||
const failedPlans = new Map<string, BrowseInstallPlan<TProject>>()
|
||||
const successfulPlans: BrowseInstallPlan<TProject>[] = []
|
||||
let completed = 0
|
||||
|
||||
for (const plan of queuedPlans) {
|
||||
try {
|
||||
await install(plan)
|
||||
successfulPlans.push(plan)
|
||||
} catch (error) {
|
||||
failedPlans.set(plan.projectId, plan)
|
||||
onError?.(error, plan)
|
||||
} finally {
|
||||
completed++
|
||||
await onProgress?.(completed, queuedPlans.length, plan)
|
||||
}
|
||||
}
|
||||
|
||||
queue.set(failedPlans)
|
||||
|
||||
return {
|
||||
ok: failedPlans.size === 0,
|
||||
successfulPlans,
|
||||
failedPlans,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ordered resolution attempts for an install request.
|
||||
*/
|
||||
function getInstallCandidates(
|
||||
selectedPreferences: BrowseInstallPreferences,
|
||||
targetPreferences: BrowseInstallPreferences,
|
||||
): InstallCandidate[] {
|
||||
const filteredPreferences = mergeInstallPreferences(selectedPreferences, targetPreferences)
|
||||
const candidates: InstallCandidate[] = []
|
||||
|
||||
if (hasPreferences(filteredPreferences)) {
|
||||
candidates.push({
|
||||
preferences: filteredPreferences,
|
||||
source: preferencesEquivalent(selectedPreferences, targetPreferences) ? 'target' : 'filtered',
|
||||
})
|
||||
} else {
|
||||
candidates.push({ preferences: {}, source: 'filtered' })
|
||||
}
|
||||
|
||||
if (
|
||||
hasPreferences(targetPreferences) &&
|
||||
preferencesDiffer(filteredPreferences, targetPreferences)
|
||||
) {
|
||||
candidates.push({ preferences: targetPreferences, source: 'target' })
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function hasPreferences(preferences: BrowseInstallPreferences) {
|
||||
return !!preferences.gameVersions?.length || !!preferences.loaders?.length
|
||||
}
|
||||
|
||||
function versionMatchesPreferences(
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
preferences: BrowseInstallPreferences,
|
||||
contentType: string,
|
||||
) {
|
||||
const gameVersionMatches =
|
||||
!preferences.gameVersions?.length ||
|
||||
version.game_versions.some((gameVersion) => preferences.gameVersions?.includes(gameVersion))
|
||||
if (!gameVersionMatches) return false
|
||||
if (contentType === 'datapack') return true
|
||||
if (!preferences.loaders?.length) return true
|
||||
|
||||
const compatibleLoaders = getCompatibleLoaderAliasSet(preferences.loaders)
|
||||
return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoaderAlias(loader)))
|
||||
}
|
||||
|
||||
function preferencesConflict(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
if (!selected?.length || !target?.length) return false
|
||||
return !selected.some((value) => target.includes(value))
|
||||
}
|
||||
|
||||
function loaderPreferencesConflict(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
if (!selected?.length || !target?.length) return false
|
||||
const selectedLoaders = getCompatibleLoaderAliasSet(selected)
|
||||
const targetLoaders = getCompatibleLoaderAliasSet(target)
|
||||
return !Array.from(selectedLoaders).some((loader) => targetLoaders.has(loader))
|
||||
}
|
||||
|
||||
function preferencesEquivalent(
|
||||
selected: BrowseInstallPreferences,
|
||||
target: BrowseInstallPreferences,
|
||||
) {
|
||||
return (
|
||||
valueSetsEquivalent(selected.gameVersions, target.gameVersions) &&
|
||||
loaderSetsEquivalent(selected.loaders, target.loaders)
|
||||
)
|
||||
}
|
||||
|
||||
function valueSetsEquivalent(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
return setsEquivalent(new Set(selected ?? []), new Set(target ?? []))
|
||||
}
|
||||
|
||||
function loaderSetsEquivalent(
|
||||
selected: readonly string[] | undefined,
|
||||
target: readonly string[] | undefined,
|
||||
) {
|
||||
return setsEquivalent(
|
||||
getCompatibleLoaderAliasSet(selected ?? []),
|
||||
getCompatibleLoaderAliasSet(target ?? []),
|
||||
)
|
||||
}
|
||||
|
||||
function getCompatibleLoaderAliasSet(loaders: readonly string[]) {
|
||||
const aliases = new Set<string>()
|
||||
for (const loader of loaders) {
|
||||
for (const alias of getCompatibleLoaderAliases(loader)) {
|
||||
aliases.add(alias)
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
function setsEquivalent(a: Set<string>, b: Set<string>) {
|
||||
if (a.size !== b.size) return false
|
||||
return Array.from(a).every((value) => b.has(value))
|
||||
}
|
||||
|
||||
function normalizeInstallPreferences(
|
||||
preferences?: BrowseInstallPreferences,
|
||||
): BrowseInstallPreferences {
|
||||
return {
|
||||
gameVersions: uniqueDefined(preferences?.gameVersions),
|
||||
loaders: uniqueDefined(preferences?.loaders),
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueDefined(values: readonly (string | null | undefined)[] = []) {
|
||||
return Array.from(
|
||||
new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value)),
|
||||
)
|
||||
}
|
||||
|
||||
function createNoCompatibleVersionError(
|
||||
contentType: BrowseInstallContentType,
|
||||
preferences: BrowseInstallPreferences,
|
||||
) {
|
||||
const versionLabel = preferences.gameVersions?.length
|
||||
? preferences.gameVersions.join(', ')
|
||||
: 'any game version'
|
||||
const loaderLabel = preferences.loaders?.length ? preferences.loaders.join(', ') : 'any loader'
|
||||
|
||||
return new Error(
|
||||
contentType === 'datapack'
|
||||
? `No compatible version found for ${versionLabel}.`
|
||||
: `No compatible version found for ${versionLabel} / ${loaderLabel}.`,
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon, MinecraftServerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { LeftArrowIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ContentPageHeader from '#ui/components/base/ContentPageHeader.vue'
|
||||
import { useServerImage } from '#ui/composables/use-server-image'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import SelectedProjectsLeaveModal from './components/SelectedProjectsLeaveModal.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
import type { BrowseInstallContext } from './types'
|
||||
|
||||
const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const router = useRouter()
|
||||
const installContext = computed(() => ctx.installContext?.value ?? null)
|
||||
const props = defineProps<{
|
||||
installContext?: BrowseInstallContext | null
|
||||
}>()
|
||||
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
|
||||
|
||||
const ctx = injectBrowseManager(null)
|
||||
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
|
||||
const selectedProjectsLeaveModal = ref<InstanceType<typeof SelectedProjectsLeaveModal>>()
|
||||
|
||||
const serverId = computed(() => installContext.value?.serverId ?? '')
|
||||
const upstream = computed(() => installContext.value?.upstream ?? null)
|
||||
@@ -27,35 +34,97 @@ const { image: fetchedIcon } = useServerImage(serverId, upstream, {
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
if (installContext.value?.isMedal) return MEDAL_ICON_URL
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? MinecraftServerIcon
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
|
||||
})
|
||||
|
||||
const metadataItems = computed(() => {
|
||||
const context = installContext.value
|
||||
if (!context) return []
|
||||
return [
|
||||
context.heading,
|
||||
context.gameVersion ? `MC ${context.gameVersion}` : '',
|
||||
context.loader ? formatLoaderLabel(context.loader) : '',
|
||||
].filter(Boolean)
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
|
||||
const isInstallingSelected = computed(() => installContext.value?.isInstallingSelected ?? false)
|
||||
|
||||
async function handleBack() {
|
||||
const context = installContext.value
|
||||
if (!context) return
|
||||
|
||||
if (selectedCount.value > 0 && !isInstallingSelected.value) {
|
||||
const result = await selectedProjectsLeaveModal.value?.prompt()
|
||||
await handleSelectedProjectsLeaveResult(result ?? 'cancel', context)
|
||||
return
|
||||
}
|
||||
|
||||
const shouldNavigate = await context.onBack?.()
|
||||
if (shouldNavigate === false) return
|
||||
|
||||
await router.push(context.backUrl)
|
||||
}
|
||||
|
||||
async function handleSelectedProjectsLeaveResult(
|
||||
result: SelectedProjectsLeaveResult,
|
||||
context: BrowseInstallContext,
|
||||
) {
|
||||
if (result === 'cancel') return
|
||||
if (result === 'install') {
|
||||
const shouldNavigate = await context.installSelected?.()
|
||||
if (shouldNavigate === false) return
|
||||
return
|
||||
}
|
||||
|
||||
if (context.discardSelectedAndBack) {
|
||||
await context.discardSelectedAndBack()
|
||||
return
|
||||
}
|
||||
|
||||
await (context.clearSelected ?? context.clearQueued)?.()
|
||||
await router.push(context.backUrl)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="installContext">
|
||||
<ContentPageHeader class="mb-2">
|
||||
<template #icon>
|
||||
<Avatar :src="iconSrc" size="64px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ installContext.name }}
|
||||
</template>
|
||||
<template #summary>
|
||||
<span class="flex items-center gap-2 text-sm font-semibold text-secondary">
|
||||
<GameIcon class="h-5 w-5 text-secondary" />
|
||||
{{ formatLoaderLabel(installContext.loader) }} {{ installContext.gameVersion }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<button @click="router.push(installContext.backUrl)">
|
||||
<LeftArrowIcon />
|
||||
{{ installContext.backLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
<h1 class="m-0 mb-1 text-xl font-extrabold">{{ installContext.heading }}</h1>
|
||||
<SelectedProjectsLeaveModal
|
||||
ref="selectedProjectsLeaveModal"
|
||||
:count="selectedCount"
|
||||
:installing="isInstallingSelected"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<ButtonStyled circular size="large">
|
||||
<button :aria-label="installContext.backLabel" @click="handleBack">
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Avatar v-if="iconSrc" :src="iconSrc" size="48px" class="shrink-0" />
|
||||
|
||||
<div class="flex min-w-0 flex-col justify-center gap-1">
|
||||
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast">
|
||||
{{ installContext.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="metadataItems.length"
|
||||
class="flex flex-wrap items-center gap-2 text-base font-medium leading-6 text-primary"
|
||||
>
|
||||
<template v-for="(item, index) in metadataItems" :key="item">
|
||||
<span
|
||||
v-if="index > 0"
|
||||
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
|
||||
/>
|
||||
<span>{{ item }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
|
||||
{{ installContext.warning }}
|
||||
</Admonition>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as SelectedProjectsFloatingBar } from './components/SelectedProjectsFloatingBar.vue'
|
||||
export * from './composables'
|
||||
export { default as BrowseInstallHeader } from './header.vue'
|
||||
export { default as BrowsePageLayout } from './layout.vue'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
@@ -12,13 +12,23 @@ import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
|
||||
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
|
||||
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import type { SortType } from '#ui/utils/search'
|
||||
|
||||
import SelectedProjectsFloatingBar from './components/SelectedProjectsFloatingBar.vue'
|
||||
import BrowseInstallHeader from './header.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
'BrowseInstallHeader',
|
||||
)
|
||||
|
||||
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
|
||||
ctx.effectiveSortTypes.value.map((st) => ({
|
||||
@@ -33,12 +43,43 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
label: String(n),
|
||||
})),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'browse.search.placeholder',
|
||||
defaultMessage:
|
||||
'Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} server {servers} other {projects}}...',
|
||||
},
|
||||
viewPrefix: {
|
||||
id: 'browse.view-prefix',
|
||||
defaultMessage: 'View:',
|
||||
},
|
||||
filterResults: {
|
||||
id: 'browse.filter-results',
|
||||
defaultMessage: 'Filter results...',
|
||||
},
|
||||
offline: {
|
||||
id: 'browse.offline',
|
||||
defaultMessage: 'You are currently offline. Connect to the internet to browse Modrinth!',
|
||||
},
|
||||
noResults: {
|
||||
id: 'browse.no-results',
|
||||
defaultMessage: 'No results found for your query!',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
|
||||
<BrowseInstallHeader />
|
||||
<div
|
||||
ref="stickyInstallHeaderRef"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||
>
|
||||
<BrowseInstallHeader />
|
||||
</div>
|
||||
</template>
|
||||
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
|
||||
|
||||
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
|
||||
|
||||
@@ -47,7 +88,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="`Search ${ctx.projectType.value}s...`"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder, { projectType: ctx.projectType.value })"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
|
||||
@@ -62,7 +103,9 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">Sort by:</span>
|
||||
<span class="font-semibold text-primary">{{
|
||||
formatMessage(commonMessages.sortByLabel)
|
||||
}}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
@@ -70,17 +113,19 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
:model-value="ctx.maxResults.value"
|
||||
:options="maxResultsOptions"
|
||||
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
|
||||
placeholder="View"
|
||||
:placeholder="formatMessage(commonMessages.viewLabel)"
|
||||
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">View:</span>
|
||||
<span class="font-semibold text-primary">{{ formatMessage(messages.viewPrefix) }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
|
||||
<ButtonStyled>
|
||||
<button @click="ctx.filtersMenuOpen.value = true">Filter results...</button>
|
||||
<button @click="ctx.filtersMenuOpen.value = true">
|
||||
{{ formatMessage(messages.filterResults) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +164,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
|
||||
</section>
|
||||
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
|
||||
You are currently offline. Connect to the internet to browse Modrinth!
|
||||
{{ formatMessage(messages.offline) }}
|
||||
</section>
|
||||
<section
|
||||
v-else-if="
|
||||
@@ -129,7 +174,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
"
|
||||
class="offline"
|
||||
>
|
||||
<p>No results found for your query!</p>
|
||||
<p>{{ formatMessage(messages.noResults) }}</p>
|
||||
</section>
|
||||
|
||||
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
|
||||
|
||||
@@ -66,6 +66,9 @@ export interface BrowseManagerContext {
|
||||
hideInstalled?: Ref<boolean>
|
||||
showHideInstalled?: ComputedRef<boolean>
|
||||
hideInstalledLabel?: ComputedRef<string>
|
||||
hideSelected?: Ref<boolean>
|
||||
showHideSelected?: ComputedRef<boolean>
|
||||
hideSelectedLabel?: ComputedRef<string>
|
||||
onInstalled?: (projectId: string) => void
|
||||
|
||||
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
|
||||
|
||||
@@ -5,10 +5,13 @@ import { computed, toValue } from 'vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const isApp = computed(() => ctx.variant === 'app')
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
@@ -80,7 +83,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
v-if="ctx.filtersMenuOpen?.value"
|
||||
class="sticky top-0 z-10 mx-1 flex items-center justify-between gap-3 border-0 border-b-[1px] border-solid border-divider bg-bg-raised px-6 py-4"
|
||||
>
|
||||
<h3 class="m-0 text-lg text-contrast">Filters</h3>
|
||||
<h3 class="m-0 text-lg text-contrast">{{ formatMessage(commonMessages.filtersLabel) }}</h3>
|
||||
<ButtonStyled circular>
|
||||
<button @click="closeFiltersMenu">
|
||||
<XIcon />
|
||||
@@ -89,16 +92,29 @@ function getFilterOpenByDefault(filterId: string): boolean {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ctx.showHideInstalled?.value"
|
||||
v-if="ctx.showHideInstalled?.value || ctx.showHideSelected?.value"
|
||||
:class="
|
||||
isApp
|
||||
? 'border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised p-4'
|
||||
? 'flex flex-col gap-3 border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
: 'card-shadow flex flex-col gap-3 rounded-2xl bg-bg-raised p-4'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="ctx.showHideInstalled?.value"
|
||||
v-model="ctx.hideInstalled!.value"
|
||||
:label="ctx.hideInstalledLabel?.value ?? 'Hide already installed content'"
|
||||
:label="
|
||||
ctx.hideInstalledLabel?.value ?? formatMessage(commonMessages.hideInstalledContentLabel)
|
||||
"
|
||||
class="filter-checkbox"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
@click.prevent.stop
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="ctx.showHideSelected?.value"
|
||||
v-model="ctx.hideSelected!.value"
|
||||
:label="
|
||||
ctx.hideSelectedLabel?.value ?? formatMessage(commonMessages.hideSelectedContentLabel)
|
||||
"
|
||||
class="filter-checkbox"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
@click.prevent.stop
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface BrowseSearchResponse {
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface BrowseSelectedProject {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string | null
|
||||
}
|
||||
|
||||
export interface BrowseInstallContext {
|
||||
name: string
|
||||
loader: string
|
||||
@@ -24,6 +30,19 @@ export interface BrowseInstallContext {
|
||||
backLabel: string
|
||||
heading: string
|
||||
warning?: string
|
||||
queuedCount?: number
|
||||
queuedLabel?: string
|
||||
clearQueued?: () => void | Promise<void>
|
||||
onBack?: () => boolean | void | Promise<boolean | void>
|
||||
selectedProjects?: BrowseSelectedProject[]
|
||||
isInstallingSelected?: boolean
|
||||
installProgress?: {
|
||||
completed: number
|
||||
total: number
|
||||
}
|
||||
clearSelected?: () => void | Promise<void>
|
||||
discardSelectedAndBack?: () => void | Promise<void>
|
||||
installSelected?: () => boolean | void | Promise<boolean | void>
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
@@ -32,7 +51,7 @@ export interface CardAction {
|
||||
icon: Component
|
||||
iconClass?: string
|
||||
disabled?: boolean
|
||||
color?: 'brand' | 'red'
|
||||
color?: 'brand' | 'red' | 'green'
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
circular?: boolean
|
||||
tooltip?: string
|
||||
|
||||
@@ -21,7 +21,7 @@ import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import Toggle from '#ui/components/base/Toggle.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { truncatedTooltip } from '#ui/utils/truncate'
|
||||
|
||||
@@ -34,6 +34,13 @@ import type {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
selectProject: {
|
||||
id: 'content.card.select-project',
|
||||
defaultMessage: 'Select {project}',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
project: ContentCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
@@ -111,7 +118,10 @@ const deleteHovered = ref(false)
|
||||
<div
|
||||
role="row"
|
||||
class="flex h-[74px] items-center justify-between gap-4 px-3"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
:class="{
|
||||
'opacity-50 grayscale': disabled && !installing,
|
||||
'opacity-50': installing,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
@@ -122,7 +132,7 @@ const deleteHovered = ref(false)
|
||||
<Checkbox
|
||||
v-if="showCheckbox"
|
||||
:model-value="selected ?? false"
|
||||
:aria-label="`Select ${project.title}`"
|
||||
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
|
||||
class="shrink-0"
|
||||
@update:model-value="selected = $event"
|
||||
/>
|
||||
|
||||
@@ -89,6 +89,7 @@ const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = u
|
||||
{
|
||||
itemHeight: 74,
|
||||
bufferSize: 5,
|
||||
initialItemCount: 20,
|
||||
enabled: toRef(props, 'virtualized'),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -119,16 +119,18 @@ const collapsedOptions = computed(() => {
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isExpanded = ref(true)
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
isExpanded.value = entry.contentRect.width >= 700
|
||||
}
|
||||
})
|
||||
let observer: ResizeObserver | null = null
|
||||
onMounted(() => {
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
isExpanded.value = entry.contentRect.width >= 700
|
||||
}
|
||||
})
|
||||
if (containerRef.value) observer.observe(containerRef.value)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { PowerIcon, PowerOffIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
@@ -93,6 +94,13 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
|
||||
const iconStackOffset = 24
|
||||
const visibleItems = computed(() => props.selectedItems.slice(0, 3))
|
||||
const overflowCount = computed(() => Math.max(0, props.selectedItems.length - 3))
|
||||
const iconStackWidth = computed(() => {
|
||||
if (props.selectedItems.length === 0) return 0
|
||||
return 32 + (visibleItems.value.length - 1 + (overflowCount.value > 0 ? 1 : 0)) * iconStackOffset
|
||||
})
|
||||
|
||||
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
|
||||
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
|
||||
@@ -130,10 +138,41 @@ const bulkProgressMessage = computed(() => {
|
||||
<template>
|
||||
<FloatingActionBar :shown="shown" :aria-label="ariaLabel">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
<div
|
||||
v-if="selectedItems.length > 0"
|
||||
class="relative h-8 shrink-0"
|
||||
:style="{ width: `${iconStackWidth}px` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.id"
|
||||
v-tooltip="item.project?.title ?? item.file_name"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center overflow-hidden rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4"
|
||||
:style="{ left: `${index * iconStackOffset}px`, zIndex: visibleItems.length - index }"
|
||||
>
|
||||
<Avatar
|
||||
:src="item.project?.icon_url"
|
||||
:alt="item.project?.title ?? item.file_name"
|
||||
:tint-by="item.id"
|
||||
size="100%"
|
||||
no-shadow
|
||||
class="selected-content-avatar"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="overflowCount > 0"
|
||||
class="absolute top-0 flex h-8 w-8 items-center justify-center rounded-lg border-[1.5px] border-solid border-surface-3 bg-surface-4 text-xs font-bold text-contrast"
|
||||
:style="{ left: `${visibleItems.length * iconStackOffset}px`, zIndex: 0 }"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ selectedCountText }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<div class="mx-0.5 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.clearButton)"
|
||||
@@ -223,4 +262,8 @@ const bulkProgressMessage = computed(() => {
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:deep(.selected-content-avatar) {
|
||||
background-color: var(--color-button-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -15,6 +14,12 @@ import {
|
||||
injectServerSettingsModal,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
type PendingServerContentInstall,
|
||||
pendingServerContentInstallsEvent,
|
||||
readPendingServerContentInstallBaseline,
|
||||
readPendingServerContentInstalls,
|
||||
} from '#ui/utils/server-content-installing'
|
||||
|
||||
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
||||
@@ -31,6 +36,19 @@ import type {
|
||||
} from '../../../shared/content-tab/types'
|
||||
|
||||
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
|
||||
type ContentOwnerAvatarSource = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'user' | 'organization'
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
ownerAvatarUrlBase?: string
|
||||
}>(),
|
||||
{
|
||||
ownerAvatarUrlBase: 'https://modrinth.com',
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -100,6 +118,11 @@ const type = computed(() => {
|
||||
|
||||
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
|
||||
|
||||
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
|
||||
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
|
||||
return `${props.ownerAvatarUrlBase}/${owner.type}/${encodeURIComponent(ownerId)}/avatar`
|
||||
}
|
||||
|
||||
const contentQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
@@ -108,8 +131,6 @@ const contentQuery = useQuery({
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const contentReadyPending = useReadyState(contentQuery)
|
||||
|
||||
const modpackProjectId = computed(() => {
|
||||
const spec = contentQuery.data.value?.modpack?.spec
|
||||
return spec?.platform === 'modrinth' ? spec.project_id : null
|
||||
@@ -163,8 +184,8 @@ const modpack = computed<ContentModpackData | null>(() => {
|
||||
? {
|
||||
id: mp.owner.id,
|
||||
name: mp.owner.name,
|
||||
avatar_url: mp.owner.icon_url ?? undefined,
|
||||
type: mp.owner.type,
|
||||
avatar_url: getContentOwnerAvatarUrl(mp.owner),
|
||||
link:
|
||||
mp.owner.type === 'organization'
|
||||
? `/organization/${mp.owner.id}`
|
||||
@@ -202,8 +223,228 @@ const addonLookup = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const contentItems = computed<ContentItem[]>(() => {
|
||||
return (contentQuery.data.value?.addons ?? []).map(addonToContentItem)
|
||||
const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
|
||||
const lastStableContentKeys = ref<Set<string>>(new Set())
|
||||
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
|
||||
const contentInstallAddedKeys = ref<Set<string>>(new Set())
|
||||
|
||||
function syncPendingServerContentInstalls() {
|
||||
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
|
||||
}
|
||||
|
||||
function handlePendingServerContentInstallsChanged(event: Event) {
|
||||
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
|
||||
.detail
|
||||
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
|
||||
syncPendingServerContentInstalls()
|
||||
}
|
||||
|
||||
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
|
||||
return addon.project_id ?? addon.filename
|
||||
}
|
||||
|
||||
function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) {
|
||||
const keys = new Set<string>()
|
||||
for (const addon of addons) {
|
||||
keys.add(getAddonInstallKey(addon))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
function syncContentInstallKeys(
|
||||
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
|
||||
) {
|
||||
const currentKeys = getAddonInstallKeys(addons)
|
||||
if (isSyncingContent.value) {
|
||||
if (!contentInstallBaselineKeys.value) {
|
||||
contentInstallBaselineKeys.value =
|
||||
readPendingServerContentInstallBaseline(serverId, worldId.value) ??
|
||||
new Set(lastStableContentKeys.value)
|
||||
}
|
||||
|
||||
const nextAddedKeys = new Set(contentInstallAddedKeys.value)
|
||||
for (const key of currentKeys) {
|
||||
if (!contentInstallBaselineKeys.value.has(key)) {
|
||||
nextAddedKeys.add(key)
|
||||
}
|
||||
}
|
||||
contentInstallAddedKeys.value = nextAddedKeys
|
||||
return
|
||||
}
|
||||
|
||||
lastStableContentKeys.value = currentKeys
|
||||
contentInstallBaselineKeys.value = null
|
||||
contentInstallAddedKeys.value = new Set()
|
||||
}
|
||||
|
||||
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
|
||||
return {
|
||||
project: {
|
||||
id: item.projectId,
|
||||
slug: item.slug ?? item.projectId,
|
||||
title: item.title,
|
||||
icon_url: item.iconUrl ?? undefined,
|
||||
},
|
||||
version: {
|
||||
id: item.versionId,
|
||||
version_number:
|
||||
item.versionName ?? item.versionNumber ?? formatMessage(commonMessages.installingLabel),
|
||||
file_name: item.fileName ?? formatMessage(commonMessages.installingLabel),
|
||||
},
|
||||
owner: item.owner
|
||||
? {
|
||||
id: item.owner.id,
|
||||
name: item.owner.name,
|
||||
type: item.owner.type,
|
||||
avatar_url: getContentOwnerAvatarUrl(item.owner),
|
||||
link: item.owner.link,
|
||||
}
|
||||
: undefined,
|
||||
id: `installing:${item.projectId}`,
|
||||
enabled: true,
|
||||
file_name: `installing:${item.projectId}`,
|
||||
project_type: item.contentType,
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
installing: true,
|
||||
}
|
||||
}
|
||||
|
||||
const rawContentItems = computed<ContentItem[]>(() => {
|
||||
const addons = contentQuery.data.value?.addons ?? []
|
||||
const pendingProjectIds = new Set(
|
||||
pendingServerContentInstalls.value.map((item) => item.projectId),
|
||||
)
|
||||
const pendingInstallByProjectId = new Map(
|
||||
pendingServerContentInstalls.value.map((item) => [item.projectId, item]),
|
||||
)
|
||||
const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value])
|
||||
const installedProjectIds = new Set(
|
||||
addons.map((addon) => addon.project_id).filter((id): id is string => !!id),
|
||||
)
|
||||
const pendingItems = pendingServerContentInstalls.value
|
||||
.filter((item) => !installedProjectIds.has(item.projectId))
|
||||
.map(pendingInstallToContentItem)
|
||||
const addonItems = addons.map((addon) => {
|
||||
const contentItem = addonToContentItem(addon)
|
||||
const installing = installingContentKeys.has(getAddonInstallKey(addon))
|
||||
const pendingItem = addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null
|
||||
|
||||
if (!installing || !pendingItem) {
|
||||
return {
|
||||
...contentItem,
|
||||
installing,
|
||||
}
|
||||
}
|
||||
|
||||
const pendingContentItem = pendingInstallToContentItem(pendingItem)
|
||||
return {
|
||||
...contentItem,
|
||||
project: {
|
||||
...contentItem.project,
|
||||
slug: pendingContentItem.project.slug,
|
||||
title: pendingContentItem.project.title,
|
||||
icon_url: contentItem.project.icon_url ?? pendingContentItem.project.icon_url,
|
||||
},
|
||||
version: {
|
||||
id: pendingContentItem.version?.id ?? contentItem.version?.id ?? contentItem.file_name,
|
||||
version_number:
|
||||
pendingContentItem.version?.version_number ??
|
||||
contentItem.version?.version_number ??
|
||||
formatMessage(commonMessages.installingLabel),
|
||||
file_name:
|
||||
pendingContentItem.version?.file_name ??
|
||||
contentItem.version?.file_name ??
|
||||
contentItem.file_name,
|
||||
},
|
||||
owner: pendingContentItem.owner ?? contentItem.owner,
|
||||
installing,
|
||||
}
|
||||
})
|
||||
|
||||
return [...addonItems, ...pendingItems]
|
||||
})
|
||||
|
||||
const displayedContentItems = ref<ContentItem[]>([])
|
||||
const contentItems = computed<ContentItem[]>(() => displayedContentItems.value)
|
||||
const contentReadyPending = computed(
|
||||
() =>
|
||||
contentQuery.isLoading.value &&
|
||||
contentQuery.data.value === undefined &&
|
||||
pendingServerContentInstalls.value.length === 0 &&
|
||||
displayedContentItems.value.length === 0,
|
||||
)
|
||||
|
||||
function getContentItemDisplayKey(item: ContentItem) {
|
||||
return item.project?.id ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
function mergeFragileContentItems(items: ContentItem[]) {
|
||||
const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item]))
|
||||
const mergedItems = displayedContentItems.value.map((item) => {
|
||||
const key = getContentItemDisplayKey(item)
|
||||
const nextItem = nextItems.get(key)
|
||||
if (!nextItem) return item
|
||||
|
||||
nextItems.delete(key)
|
||||
return nextItem
|
||||
})
|
||||
|
||||
return [...mergedItems, ...nextItems.values()]
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
rawContentItems,
|
||||
isSyncingContent,
|
||||
() => contentQuery.isFetching.value,
|
||||
() => contentQuery.isLoading.value,
|
||||
],
|
||||
([items, syncing, isFetching, isLoading]) => {
|
||||
if (syncing) {
|
||||
if (items.length > 0) {
|
||||
displayedContentItems.value = mergeFragileContentItems(items)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (items.length > 0 || (!isFetching && !isLoading)) {
|
||||
displayedContentItems.value = items
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[isSyncingContent, () => contentQuery.data.value?.addons],
|
||||
([, addons]) => {
|
||||
syncContentInstallKeys(addons ?? [])
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
worldId,
|
||||
() => {
|
||||
syncPendingServerContentInstalls()
|
||||
syncContentInstallKeys()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncPendingServerContentInstalls()
|
||||
window.addEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -471,7 +712,7 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
|
||||
id: addon.owner.id,
|
||||
name: addon.owner.name,
|
||||
type: addon.owner.type,
|
||||
avatar_url: addon.owner.icon_url ?? undefined,
|
||||
avatar_url: getContentOwnerAvatarUrl(addon.owner),
|
||||
link: `/${addon.owner.type}/${addon.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
@@ -785,7 +1026,14 @@ provideContentManager({
|
||||
isPackLocked: ref(false),
|
||||
isBusy: computed(() => busyReasons.value.length > 0),
|
||||
busyMessage: computed(() => {
|
||||
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
|
||||
const bannerCoversInstalling =
|
||||
server.value?.status === 'installing' ||
|
||||
isSyncingContent.value ||
|
||||
busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' ||
|
||||
r.reason.id === 'servers.busy.syncing-content',
|
||||
)
|
||||
const filteredReasons = busyReasons.value.filter((r) => {
|
||||
if (
|
||||
bannerCoversInstalling &&
|
||||
@@ -826,18 +1074,19 @@ provideContentManager({
|
||||
mapToTableItem: (item) => {
|
||||
const projectType = item.project_type ?? type.value
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
const hasModrinthProject = !!addon?.project_id
|
||||
const hasModrinthProject = !!addon?.project_id || (!!item.installing && !!item.project?.id)
|
||||
const projectSlugOrId = item.project.slug ?? item.project.id
|
||||
return {
|
||||
id: item.id,
|
||||
project: item.project,
|
||||
projectLink: hasModrinthProject ? `/${projectType}/${item.project.id}` : undefined,
|
||||
projectLink: hasModrinthProject ? `/${projectType}/${projectSlugOrId}` : undefined,
|
||||
version: item.version,
|
||||
versionLink:
|
||||
hasModrinthProject && item.version?.id
|
||||
? `/${projectType}/${item.project.id}/version/${item.version.id}`
|
||||
? `/${projectType}/${projectSlugOrId}/version/${item.version.id}`
|
||||
: undefined,
|
||||
owner: item.owner
|
||||
? { ...item.owner, link: `/${item.owner.type}/${item.owner.id}` }
|
||||
? { ...item.owner, link: item.owner.link ?? `/${item.owner.type}/${item.owner.id}` }
|
||||
: undefined,
|
||||
enabled: item.enabled,
|
||||
}
|
||||
|
||||
@@ -311,7 +311,6 @@
|
||||
class="mb-4"
|
||||
:sync-progress="syncProgress"
|
||||
:content-error="contentError"
|
||||
:server-image="serverImage"
|
||||
@content-retry="handleContentRetry"
|
||||
/>
|
||||
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
|
||||
@@ -404,6 +403,11 @@ import {
|
||||
provideServerSettingsModal,
|
||||
} from '#ui/providers'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
import {
|
||||
pendingServerContentInstallsEvent,
|
||||
readPendingServerContentInstalls,
|
||||
writePendingServerContentInstalls,
|
||||
} from '#ui/utils/server-content-installing'
|
||||
|
||||
import ServerOnboardingPanelPage from './[id]/onboarding.vue'
|
||||
|
||||
@@ -571,6 +575,8 @@ const { data: serverProject } = useServerProject(computed(() => serverData.value
|
||||
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
|
||||
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
|
||||
const syncProgressActive = ref(false)
|
||||
const hasPendingServerContentInstalls = ref(false)
|
||||
const hasSeenPendingServerContentSync = ref(false)
|
||||
const isAwaitingPostInstallRefresh = ref(false)
|
||||
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
|
||||
() => (syncProgressActive.value = false),
|
||||
@@ -582,15 +588,45 @@ watch(syncProgress, (progress) => {
|
||||
if (progress != null) {
|
||||
cancelSyncHide()
|
||||
syncProgressActive.value = true
|
||||
if (progress.phase !== 'Analyzing' && hasPendingServerContentInstalls.value) {
|
||||
hasSeenPendingServerContentSync.value = true
|
||||
}
|
||||
} else if (syncProgressActive.value) {
|
||||
startSyncHide()
|
||||
if (hasSeenPendingServerContentSync.value) {
|
||||
writePendingServerContentInstalls(props.serverId, worldId.value, [])
|
||||
hasSeenPendingServerContentSync.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(contentError, (error) => {
|
||||
if (!error || !hasPendingServerContentInstalls.value) return
|
||||
writePendingServerContentInstalls(props.serverId, worldId.value, [])
|
||||
hasSeenPendingServerContentSync.value = false
|
||||
})
|
||||
|
||||
const isSyncingContent = computed(
|
||||
() => syncProgressActive.value || isAwaitingPostInstallRefresh.value,
|
||||
() =>
|
||||
syncProgressActive.value ||
|
||||
isAwaitingPostInstallRefresh.value ||
|
||||
hasPendingServerContentInstalls.value,
|
||||
)
|
||||
|
||||
function syncPendingServerContentInstalls() {
|
||||
hasPendingServerContentInstalls.value =
|
||||
readPendingServerContentInstalls(props.serverId, worldId.value).length > 0
|
||||
}
|
||||
|
||||
function handlePendingServerContentInstallsChanged(event: Event) {
|
||||
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
|
||||
.detail
|
||||
if (detail?.serverId !== props.serverId || detail?.worldId !== worldId.value) return
|
||||
syncPendingServerContentInstalls()
|
||||
}
|
||||
|
||||
watch(worldId, syncPendingServerContentInstalls, { immediate: true })
|
||||
|
||||
let hasSeenInstallProgress = false
|
||||
|
||||
const onStateEvent = (data: Archon.Websocket.v0.WSStateEvent) => {
|
||||
@@ -1346,6 +1382,11 @@ const cleanup = () => {
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
syncPendingServerContentInstalls()
|
||||
window.addEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
|
||||
if (serverData.value) {
|
||||
initializeServer()
|
||||
@@ -1434,6 +1475,10 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -95,9 +95,48 @@
|
||||
"billing.resubscribe-modal.title": {
|
||||
"defaultMessage": "Resubscribe to Server"
|
||||
},
|
||||
"browse.filter-results": {
|
||||
"defaultMessage": "Filter results..."
|
||||
},
|
||||
"browse.no-results": {
|
||||
"defaultMessage": "No results found for your query!"
|
||||
},
|
||||
"browse.offline": {
|
||||
"defaultMessage": "You are currently offline. Connect to the internet to browse Modrinth!"
|
||||
},
|
||||
"browse.search.placeholder": {
|
||||
"defaultMessage": "Search {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} server {servers} other {projects}}..."
|
||||
},
|
||||
"browse.selected-projects-floating-bar.aria-label": {
|
||||
"defaultMessage": "Selected projects"
|
||||
},
|
||||
"browse.selected-projects-floating-bar.install": {
|
||||
"defaultMessage": "Install {count, plural, one {# project} other {# projects}}"
|
||||
},
|
||||
"browse.selected-projects-floating-bar.selected-count": {
|
||||
"defaultMessage": "{count, plural, one {# project selected} other {# projects selected}}"
|
||||
},
|
||||
"browse.selected-projects-leave-modal.admonition-body": {
|
||||
"defaultMessage": "You have selected {count, plural, one {# project} other {# projects}} to install. Install them now or go back without installing them."
|
||||
},
|
||||
"browse.selected-projects-leave-modal.admonition-header": {
|
||||
"defaultMessage": "Selected projects not installed yet"
|
||||
},
|
||||
"browse.selected-projects-leave-modal.discard": {
|
||||
"defaultMessage": "Discard"
|
||||
},
|
||||
"browse.selected-projects-leave-modal.header": {
|
||||
"defaultMessage": "Selected projects not installed yet"
|
||||
},
|
||||
"browse.view-prefix": {
|
||||
"defaultMessage": "View:"
|
||||
},
|
||||
"button.accept": {
|
||||
"defaultMessage": "Accept"
|
||||
},
|
||||
"button.add-server-to-instance": {
|
||||
"defaultMessage": "Add server to instance"
|
||||
},
|
||||
"button.affiliate-links": {
|
||||
"defaultMessage": "Affiliate links"
|
||||
},
|
||||
@@ -167,6 +206,9 @@
|
||||
"button.hide-snapshots": {
|
||||
"defaultMessage": "Hide snapshots"
|
||||
},
|
||||
"button.install": {
|
||||
"defaultMessage": "Install"
|
||||
},
|
||||
"button.max": {
|
||||
"defaultMessage": "Max"
|
||||
},
|
||||
@@ -185,6 +227,9 @@
|
||||
"button.open-in-folder": {
|
||||
"defaultMessage": "Open in folder"
|
||||
},
|
||||
"button.open-in-modrinth": {
|
||||
"defaultMessage": "Open in Modrinth"
|
||||
},
|
||||
"button.play": {
|
||||
"defaultMessage": "Play"
|
||||
},
|
||||
@@ -278,6 +323,9 @@
|
||||
"collections.label.private": {
|
||||
"defaultMessage": "Private"
|
||||
},
|
||||
"content.card.select-project": {
|
||||
"defaultMessage": "Select {project}"
|
||||
},
|
||||
"content.confirm-bulk-update.admonition-body": {
|
||||
"defaultMessage": "Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one."
|
||||
},
|
||||
@@ -1721,21 +1769,36 @@
|
||||
"label.filter-by": {
|
||||
"defaultMessage": "Filter by"
|
||||
},
|
||||
"label.filters": {
|
||||
"defaultMessage": "Filters"
|
||||
},
|
||||
"label.followed-projects": {
|
||||
"defaultMessage": "Followed projects"
|
||||
},
|
||||
"label.game-version": {
|
||||
"defaultMessage": "Game version"
|
||||
},
|
||||
"label.hide-installed-content": {
|
||||
"defaultMessage": "Hide already installed content"
|
||||
},
|
||||
"label.hide-selected-content": {
|
||||
"defaultMessage": "Hide selected content"
|
||||
},
|
||||
"label.installation-info": {
|
||||
"defaultMessage": "Installation info"
|
||||
},
|
||||
"label.installed": {
|
||||
"defaultMessage": "Installed"
|
||||
},
|
||||
"label.installed-modpack": {
|
||||
"defaultMessage": "Installed modpack"
|
||||
},
|
||||
"label.installing": {
|
||||
"defaultMessage": "Installing..."
|
||||
},
|
||||
"label.installing-content": {
|
||||
"defaultMessage": "Installing content"
|
||||
},
|
||||
"label.loading": {
|
||||
"defaultMessage": "Loading..."
|
||||
},
|
||||
@@ -1811,6 +1874,9 @@
|
||||
"label.select-all": {
|
||||
"defaultMessage": "Select all"
|
||||
},
|
||||
"label.selected": {
|
||||
"defaultMessage": "Selected"
|
||||
},
|
||||
"label.selection-actions": {
|
||||
"defaultMessage": "Selection actions"
|
||||
},
|
||||
@@ -1853,9 +1919,15 @@
|
||||
"label.username": {
|
||||
"defaultMessage": "Username"
|
||||
},
|
||||
"label.validating": {
|
||||
"defaultMessage": "Validating"
|
||||
},
|
||||
"label.version": {
|
||||
"defaultMessage": "Version"
|
||||
},
|
||||
"label.view": {
|
||||
"defaultMessage": "View"
|
||||
},
|
||||
"label.visibility": {
|
||||
"defaultMessage": "Visibility"
|
||||
},
|
||||
@@ -3119,6 +3191,54 @@
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Content sync in progress"
|
||||
},
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installation failed"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "An internal error occurred while installing the platform. Please try again."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "The specified loader or Minecraft version could not be installed. It may be invalid or unsupported."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "The modpack could not be installed. It may be corrupted or incompatible."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "This modpack version does not include a downloadable file. It may have been packaged incorrectly."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "An unexpected error occurred during installation."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "This version of Minecraft or loader is not yet supported by Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Installing addons..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Installing modpack..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Installing platform..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "We're preparing your server"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Adding Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configuring server..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Downloading mods..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizing files..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Setting up environment..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Already have a server?"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'project-type.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
addServerToInstanceButton: {
|
||||
id: 'button.add-server-to-instance',
|
||||
defaultMessage: 'Add server to instance',
|
||||
},
|
||||
backButton: {
|
||||
id: 'button.back',
|
||||
defaultMessage: 'Back',
|
||||
@@ -133,6 +137,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.filter-by',
|
||||
defaultMessage: 'Filter by',
|
||||
},
|
||||
filtersLabel: {
|
||||
id: 'label.filters',
|
||||
defaultMessage: 'Filters',
|
||||
},
|
||||
followButton: {
|
||||
id: 'button.follow',
|
||||
defaultMessage: 'Follow',
|
||||
@@ -189,6 +197,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'button.open-folder',
|
||||
defaultMessage: 'Open folder',
|
||||
},
|
||||
openInModrinthButton: {
|
||||
id: 'button.open-in-modrinth',
|
||||
defaultMessage: 'Open in Modrinth',
|
||||
},
|
||||
orLabel: {
|
||||
id: 'label.or',
|
||||
defaultMessage: 'or',
|
||||
@@ -385,6 +397,34 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.installation-info',
|
||||
defaultMessage: 'Installation info',
|
||||
},
|
||||
installButton: {
|
||||
id: 'button.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
installedLabel: {
|
||||
id: 'label.installed',
|
||||
defaultMessage: 'Installed',
|
||||
},
|
||||
validatingLabel: {
|
||||
id: 'label.validating',
|
||||
defaultMessage: 'Validating',
|
||||
},
|
||||
selectedLabel: {
|
||||
id: 'label.selected',
|
||||
defaultMessage: 'Selected',
|
||||
},
|
||||
installingContentLabel: {
|
||||
id: 'label.installing-content',
|
||||
defaultMessage: 'Installing content',
|
||||
},
|
||||
hideInstalledContentLabel: {
|
||||
id: 'label.hide-installed-content',
|
||||
defaultMessage: 'Hide already installed content',
|
||||
},
|
||||
hideSelectedContentLabel: {
|
||||
id: 'label.hide-selected-content',
|
||||
defaultMessage: 'Hide selected content',
|
||||
},
|
||||
installedModpackTitle: {
|
||||
id: 'label.installed-modpack',
|
||||
defaultMessage: 'Installed modpack',
|
||||
@@ -451,6 +491,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.version',
|
||||
defaultMessage: 'Version',
|
||||
},
|
||||
viewLabel: {
|
||||
id: 'label.view',
|
||||
defaultMessage: 'View',
|
||||
},
|
||||
projectLabel: {
|
||||
id: 'label.project',
|
||||
defaultMessage: 'Project',
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './loaders'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './server-content-installing'
|
||||
export * from './server-search'
|
||||
export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
|
||||
203
packages/ui/src/utils/server-content-installing.ts
Normal file
203
packages/ui/src/utils/server-content-installing.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
ContentCardProject,
|
||||
ContentCardVersion,
|
||||
ContentOwner,
|
||||
} from '../layouts/shared/content-tab/types'
|
||||
|
||||
export type PendingServerContentInstallType = 'mod' | 'plugin' | 'datapack'
|
||||
type PendingServerContentOwner = Omit<ContentOwner, 'link'> & { link?: string }
|
||||
|
||||
export interface PendingServerContentInstall {
|
||||
projectId: string
|
||||
versionId: string
|
||||
contentType: PendingServerContentInstallType
|
||||
title: ContentCardProject['title']
|
||||
versionName?: ContentCardVersion['version_number'] | null
|
||||
versionNumber?: ContentCardVersion['version_number'] | null
|
||||
fileName?: ContentCardVersion['file_name'] | null
|
||||
owner?: PendingServerContentOwner | null
|
||||
slug?: ContentCardProject['slug'] | null
|
||||
iconUrl?: ContentCardProject['icon_url'] | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface PendingServerContentInstallBaseline {
|
||||
contentKeys: string[]
|
||||
projectIds?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export const pendingServerContentInstallsEvent = 'modrinth:pending-server-content-installs'
|
||||
|
||||
const stalePendingInstallAge = 30 * 60 * 1000
|
||||
|
||||
function getPendingServerContentInstallsKey(serverId: string | null, worldId: string | null) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `server-content-installing:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
function getPendingServerContentInstallBaselineKey(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `server-content-installing-baseline:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
function isPendingServerContentInstall(value: unknown): value is PendingServerContentInstall {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
typeof record.projectId === 'string' &&
|
||||
typeof record.versionId === 'string' &&
|
||||
(record.contentType === 'mod' ||
|
||||
record.contentType === 'plugin' ||
|
||||
record.contentType === 'datapack') &&
|
||||
typeof record.title === 'string' &&
|
||||
typeof record.createdAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isPendingServerContentInstallBaseline(
|
||||
value: unknown,
|
||||
): value is PendingServerContentInstallBaseline {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
const contentKeys = record.contentKeys ?? record.projectIds
|
||||
return (
|
||||
Array.isArray(contentKeys) &&
|
||||
contentKeys.every((contentKey) => typeof contentKey === 'string') &&
|
||||
typeof record.createdAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function filterFreshPendingServerContentInstalls(items: PendingServerContentInstall[]) {
|
||||
const cutoff = Date.now() - stalePendingInstallAge
|
||||
return items.filter((item) => item.createdAt >= cutoff)
|
||||
}
|
||||
|
||||
function isFreshPendingServerContentInstallBaseline(item: PendingServerContentInstallBaseline) {
|
||||
return item.createdAt >= Date.now() - stalePendingInstallAge
|
||||
}
|
||||
|
||||
function emitPendingServerContentInstallsChanged(serverId: string | null, worldId: string | null) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(pendingServerContentInstallsEvent, {
|
||||
detail: { serverId, worldId },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function readPendingServerContentInstalls(serverId: string | null, worldId: string | null) {
|
||||
const key = getPendingServerContentInstallsKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return []
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const freshItems = filterFreshPendingServerContentInstalls(
|
||||
parsed.filter(isPendingServerContentInstall),
|
||||
)
|
||||
if (freshItems.length !== parsed.length) {
|
||||
writePendingServerContentInstalls(serverId, worldId, freshItems)
|
||||
}
|
||||
return freshItems
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingServerContentInstalls(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
items: PendingServerContentInstall[],
|
||||
) {
|
||||
const key = getPendingServerContentInstallsKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
const freshItems = filterFreshPendingServerContentInstalls(items)
|
||||
if (freshItems.length === 0) {
|
||||
localStorage.removeItem(key)
|
||||
const baselineKey = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (baselineKey) {
|
||||
localStorage.removeItem(baselineKey)
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(freshItems))
|
||||
}
|
||||
emitPendingServerContentInstallsChanged(serverId, worldId)
|
||||
}
|
||||
|
||||
export function readPendingServerContentInstallBaseline(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
) {
|
||||
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!isPendingServerContentInstallBaseline(parsed)) return null
|
||||
if (!isFreshPendingServerContentInstallBaseline(parsed)) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
return new Set(parsed.contentKeys ?? parsed.projectIds)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingServerContentInstallBaseline(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
contentKeys: Iterable<string>,
|
||||
) {
|
||||
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
contentKeys: Array.from(new Set(contentKeys)),
|
||||
createdAt: Date.now(),
|
||||
} satisfies PendingServerContentInstallBaseline),
|
||||
)
|
||||
emitPendingServerContentInstallsChanged(serverId, worldId)
|
||||
}
|
||||
|
||||
export function addPendingServerContentInstalls(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
items: Omit<PendingServerContentInstall, 'createdAt'>[],
|
||||
) {
|
||||
if (items.length === 0) return
|
||||
|
||||
const now = Date.now()
|
||||
const next = new Map(
|
||||
readPendingServerContentInstalls(serverId, worldId).map((item) => [item.projectId, item]),
|
||||
)
|
||||
for (const item of items) {
|
||||
next.set(item.projectId, { ...item, createdAt: now })
|
||||
}
|
||||
writePendingServerContentInstalls(serverId, worldId, Array.from(next.values()))
|
||||
}
|
||||
|
||||
export function removePendingServerContentInstall(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
projectId: string,
|
||||
) {
|
||||
writePendingServerContentInstalls(
|
||||
serverId,
|
||||
worldId,
|
||||
readPendingServerContentInstalls(serverId, worldId).filter(
|
||||
(item) => item.projectId !== projectId,
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user