feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 15:58:01 +00:00
committed by GitHub
co-authored by Prospector
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
@@ -1,7 +1,11 @@
<template>
<div class="flex items-center gap-1">
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
<button @click="emit('clear')">
<button
v-tooltip="clearDisabled ? clearDisabledTooltip : undefined"
:disabled="clearDisabled"
@click="emit('clear')"
>
<XIcon />
Clear
</button>
@@ -56,6 +60,8 @@ defineProps<{
shareDisabledTooltip?: string
sharing?: boolean
fullscreen?: boolean
clearDisabled?: boolean
clearDisabledTooltip?: string
showDelete?: boolean
deleteDisabled?: boolean
deleteDisabledTooltip?: string
@@ -44,6 +44,8 @@
:share-disabled="resolvedShareDisabled"
:sharing="isSharing"
:fullscreen="isFullscreen"
:clear-disabled="resolvedClearDisabled"
:clear-disabled-tooltip="resolvedClearDisabledTooltip"
:show-delete="showDelete"
:delete-disabled="resolvedDeleteDisabled"
:delete-disabled-tooltip="ctx.deleteDisabledTooltip"
@@ -59,6 +61,8 @@
class="min-h-0 flex-1"
:show-input="resolvedShowInput"
:disable-input="resolvedInputDisabled"
:disable-input-tooltip="resolvedInputDisabledTooltip"
:disabled-input-placeholder="resolvedInputDisabledPlaceholder"
:fullscreen="isFullscreen"
:empty-state-type="ctx.emptyStateType"
:loading="resolvedLoading"
@@ -217,6 +221,11 @@ const resolvedDisableInput = computed(() => {
return isRef(v) ? v.value : v
})
function unwrapMaybeRef<T>(value: T | { value: T } | undefined): T | undefined {
if (value === undefined) return undefined
return isRef(value) ? value.value : value
}
// needs historical log start/end flags on ws to be properly useful
const resolvedLoading = computed(() => {
const v = ctx.loading
@@ -226,6 +235,14 @@ const resolvedLoading = computed(() => {
const resolvedInputDisabled = computed(() => resolvedDisableInput.value || resolvedLoading.value)
const resolvedInputDisabledTooltip = computed(() =>
resolvedDisableInput.value ? unwrapMaybeRef(ctx.disableCommandInputTooltip) : undefined,
)
const resolvedInputDisabledPlaceholder = computed(() =>
resolvedInputDisabledTooltip.value ? 'Command input disabled' : 'Server is not running',
)
const resolvedShareDisabled = computed(() => {
const v = ctx.shareDisabled
if (!v) return false
@@ -240,6 +257,16 @@ const resolvedDeleteDisabled = computed(() => {
return isRef(v) ? v.value : v
})
const resolvedClearDisabled = computed(() => {
const v = ctx.clearDisabled
if (!v) return false
return isRef(v) ? v.value : v
})
const resolvedClearDisabledTooltip = computed(() =>
resolvedClearDisabled.value ? unwrapMaybeRef(ctx.clearDisabledTooltip) : undefined,
)
function handleTerminalReady(_terminal: Terminal) {
rewriteFiltered()
}
@@ -360,10 +387,12 @@ watch(resolvedLoading, (loading) => {
})
function handleCommand(cmd: string) {
if (resolvedInputDisabled.value) return
ctx.sendCommand?.(cmd)
}
function handleClear() {
if (resolvedClearDisabled.value) return
const term = terminalRef.value?.terminal
if (term) clearSearchHighlights(term)
terminalRef.value?.reset()
@@ -14,10 +14,13 @@ export interface ConsoleManagerContext {
sendCommand?: (cmd: string) => void
showCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
disableCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
disableCommandInputTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
loading?: Ref<boolean> | ComputedRef<boolean>
onClear?: () => void
clearDisabled?: Ref<boolean> | ComputedRef<boolean>
clearDisabledTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
onDelete?: () => Promise<void>
deleteDisabled?: Ref<boolean> | ComputedRef<boolean>
deleteDisabledTooltip?: string
@@ -55,6 +55,9 @@ interface Props {
hideSwitchVersion?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
disabledTooltip?: string | null
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
@@ -73,6 +76,9 @@ const props = withDefaults(defineProps<Props>(), {
hideSwitchVersion: false,
overflowOptions: undefined,
disabled: false,
disabledTooltip: undefined,
toggleDisabled: false,
toggleDisabledTooltip: undefined,
showCheckbox: false,
hideDelete: false,
hideActions: false,
@@ -98,6 +104,7 @@ const versionNumberRef = ref<HTMLElement | null>(null)
const fileNameRef = ref<HTMLElement | null>(null)
const isDisabled = computed(() => props.disabled || props.installing)
const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
const clientWarningMessage = computed(() => {
switch (props.clientWarning) {
@@ -173,8 +180,19 @@ const deleteHovered = ref(false)
>
{{ project.title }}
</AutoLink>
<Tooltip v-if="isClientOnly">
<TriangleAlertIcon class="size-4 shrink-0 text-orange" />
<Tooltip
v-if="isClientOnly"
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
>
<span
class="inline-flex size-5 shrink-0 cursor-help items-center justify-center"
tabindex="0"
>
<TriangleAlertIcon class="pointer-events-none size-4 text-orange" />
</span>
<template #popper>
<div class="max-w-[18rem] text-sm">
{{ formatMessage(clientWarningMessage) }}
@@ -283,7 +301,11 @@ const deleteHovered = ref(false)
hover-color-fill="background"
>
<button
v-tooltip="formatMessage(commonMessages.updateAvailableLabel)"
v-tooltip="
isDisabled && disabledTooltip
? disabledTooltip
: formatMessage(commonMessages.updateAvailableLabel)
"
:disabled="isDisabled"
@click="emit('update')"
>
@@ -296,7 +318,11 @@ const deleteHovered = ref(false)
type="transparent"
>
<button
v-tooltip="formatMessage(commonMessages.switchVersionButton)"
v-tooltip="
isDisabled && disabledTooltip
? disabledTooltip
: formatMessage(commonMessages.switchVersionButton)
"
:disabled="isDisabled"
@click="emit('switchVersion')"
>
@@ -307,8 +333,13 @@ const deleteHovered = ref(false)
<Toggle
v-if="enabled !== undefined"
v-tooltip="
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
? (toggleDisabledTooltip ?? disabledTooltip)
: undefined
"
:model-value="enabled"
:disabled="isDisabled"
:disabled="isToggleDisabled"
:aria-label="project.title"
class="my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
@@ -317,11 +348,13 @@ const deleteHovered = ref(false)
<ButtonStyled v-if="hasDeleteListener && !props.hideDelete" circular type="transparent">
<button
v-tooltip="
formatMessage(
shiftHeld && deleteHovered
? commonMessages.deleteImmediatelyLabel
: commonMessages.deleteLabel,
)
isDisabled && disabledTooltip
? disabledTooltip
: formatMessage(
shiftHeld && deleteHovered
? commonMessages.deleteImmediatelyLabel
: commonMessages.deleteLabel,
)
"
:disabled="isDisabled"
@click="emit('delete', $event)"
@@ -281,6 +281,9 @@ function handleSort(column: ContentCardTableSortColumn) {
:hide-switch-version="item.hideSwitchVersion"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
@@ -336,6 +339,9 @@ function handleSort(column: ContentCardTableSortColumn) {
:client-warning="item.clientWarning"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
@@ -224,6 +224,7 @@ onUnmounted(() => {
<Tooltip
v-if="hasContentListener"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && isExpanded"
:auto-hide="false"
@@ -293,6 +294,7 @@ onUnmounted(() => {
<Tooltip
v-if="collapsedOptions.length"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && !isExpanded"
:auto-hide="false"
@@ -68,6 +68,7 @@ interface Props {
selectedItems: ContentItem[]
contentTypeLabel?: string
isBusy?: boolean
busyTooltip?: string | null
isBulkOperating?: boolean
bulkOperation?: BulkOperationType | null
bulkProgress?: number
@@ -80,6 +81,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
contentTypeLabel: undefined,
isBusy: false,
busyTooltip: undefined,
isBulkOperating: false,
bulkOperation: null,
bulkProgress: 0,
@@ -196,9 +198,11 @@ const bulkProgressMessage = computed(() => {
<ButtonStyled type="transparent">
<button
v-tooltip="
allEnabled
? formatMessage(messages.allAlreadyEnabled)
: formatMessage(commonMessages.enableButton)
isBusy && busyTooltip
? busyTooltip
: allEnabled
? formatMessage(messages.allAlreadyEnabled)
: formatMessage(commonMessages.enableButton)
"
:disabled="isBusy || allEnabled"
@click="emit('enable')"
@@ -210,9 +214,11 @@ const bulkProgressMessage = computed(() => {
<ButtonStyled type="transparent">
<button
v-tooltip="
allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(commonMessages.disableButton)
isBusy && busyTooltip
? busyTooltip
: allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(commonMessages.disableButton)
"
:disabled="isBusy || allDisabled"
@click="emit('disable')"
@@ -8,11 +8,13 @@
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { count }) }}
{{ formatMessage(messages.admonitionBody, { count: props.count }) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
:backup-name="
props.backupTip ? `Before bulk update (${props.backupTip})` : 'Before bulk update'
"
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
@update:buttons-disabled="buttonsDisabled = $event"
/>
@@ -27,9 +29,13 @@
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="confirm">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<DownloadIcon />
{{ formatMessage(messages.updateButton, { count }) }}
{{ formatMessage(messages.updateButton, { count: props.count }) }}
</button>
</ButtonStyled>
</div>
@@ -76,10 +82,12 @@ const messages = defineMessages({
},
})
defineProps<{
const props = defineProps<{
count: number
server?: boolean
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -95,6 +103,7 @@ function show() {
}
function confirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('update')
}
@@ -3,23 +3,23 @@
ref="modal"
:header="
formatMessage(messages.header, {
itemType: formatContentTypeSentence(formatMessage, itemType, count),
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
})
"
:fade="variant === 'server' ? 'warning' : 'danger'"
:fade="props.variant === 'server' ? 'warning' : 'danger'"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition
:type="variant === 'server' ? 'warning' : 'critical'"
:type="props.variant === 'server' ? 'warning' : 'critical'"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before deletion (${backupTip})` : 'Before deletion'"
:backup-name="props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion'"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -32,13 +32,17 @@
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled :color="variant === 'server' ? 'orange' : 'red'">
<button :disabled="buttonsDisabled" @click="confirm">
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<TrashIcon />
{{
formatMessage(messages.deleteButton, {
count,
itemType: formatContentTypeSentence(formatMessage, itemType, count),
count: props.count,
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
})
}}
</button>
@@ -82,16 +86,20 @@ const messages = defineMessages({
},
})
withDefaults(
const props = withDefaults(
defineProps<{
count: number
itemType: string
variant?: 'instance' | 'server'
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
variant: 'instance',
backupTip: undefined,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
@@ -108,6 +116,7 @@ function show() {
}
function confirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('delete')
}
@@ -35,7 +35,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="handleConfirm">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="handleConfirm"
>
<DownloadIcon />
{{
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
@@ -62,6 +66,8 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
const props = defineProps<{
downgrade?: boolean
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>()
const { formatMessage } = useVIntl()
@@ -106,6 +112,7 @@ function show() {
}
function handleConfirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('confirm')
}
@@ -43,12 +43,14 @@ import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ConfirmReinstallModal')
const messages = defineMessages({
header: {
@@ -84,12 +86,27 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
debug('show: called', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
})
modal.value?.show()
debug('show: returned from modal.show', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
})
}
function confirm() {
debug('confirm: called', {
hasModalRef: !!modal.value,
buttonsDisabled: buttonsDisabled.value,
})
modal.value?.hide()
emit('reinstall')
debug('confirm: emitted reinstall')
}
defineExpose({
@@ -37,6 +37,7 @@ import { ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -45,6 +46,7 @@ defineProps<{
}>()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ConfirmRepairModal')
const messages = defineMessages({
header: {
@@ -82,12 +84,16 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
debug('show: called', { hasModalRef: !!modal.value })
modal.value?.show()
debug('show: returned from modal.show', { hasModalRef: !!modal.value })
}
function confirm() {
debug('confirm: called', { hasModalRef: !!modal.value })
modal.value?.hide()
emit('repair')
debug('confirm: emitted repair')
}
defineExpose({
@@ -12,7 +12,7 @@
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before unlink (${backupTip})` : 'Before unlink'"
:backup-name="props.backupTip ? `Before unlink (${props.backupTip})` : 'Before unlink'"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -26,9 +26,13 @@
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="confirm">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<UnlinkIcon />
{{ formatMessage(messages.unlinkButton) }}
{{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
@@ -43,17 +47,21 @@ import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
defineProps<{
const props = defineProps<{
server?: boolean
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ConfirmUnlinkModal')
const messages = defineMessages({
header: {
@@ -84,12 +92,34 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
debug('show: called', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
actionDisabled: props.actionDisabled,
})
modal.value?.show()
debug('show: returned from modal.show', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
actionDisabled: props.actionDisabled,
})
}
function confirm() {
debug('confirm: called', {
hasModalRef: !!modal.value,
buttonsDisabled: buttonsDisabled.value,
actionDisabled: props.actionDisabled,
})
if (props.actionDisabled) {
debug('confirm: ignored actionDisabled')
return
}
modal.value?.hide()
emit('unlink')
debug('confirm: emitted unlink')
}
defineExpose({
@@ -114,14 +114,14 @@
inst.name
}}</span>
</button>
<ButtonStyled v-if="inst.installed" :disabled="true">
<button>
<ButtonStyled v-if="inst.installed">
<button disabled>
<CheckIcon />
{{ formatMessage(messages.installedBadge) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="inst.compatible" :disabled="inst.installing">
<button @click="emit('install', inst)">
<ButtonStyled v-else-if="inst.compatible">
<button :disabled="inst.installing" @click="emit('install', inst)">
{{
inst.installing
? formatMessage(commonMessages.installingLabel)
@@ -216,7 +216,10 @@
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!selectedVersion || selectedVersion.id === currentVersionId"
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="
props.actionDisabled || !selectedVersion || selectedVersion.id === currentVersionId
"
@click="handleUpdate"
>
<DownloadIcon />
@@ -393,6 +396,8 @@ const props = withDefaults(
loading?: boolean
/** Whether changelog is being loaded for the selected version */
loadingChangelog?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
projectType: undefined,
@@ -401,6 +406,8 @@ const props = withDefaults(
header: undefined,
loading: false,
loadingChangelog: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
@@ -616,6 +623,7 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
}
function handleUpdate(event: MouseEvent) {
if (props.actionDisabled) return
if (selectedVersion.value) {
const changesGameVersion = versionChangesGameVersion(
selectedVersion.value,
@@ -13,13 +13,17 @@
<ButtonStyled v-if="!backup.backupComplete.value && !backup.backupFailed.value">
<button
v-tooltip="
backup.externalBackupInProgress.value
? formatMessage(messages.backupInProgress)
: undefined
!canManageBackups
? permissionDeniedMessage
: backup.externalBackupInProgress.value
? formatMessage(messages.backupInProgress)
: undefined
"
class="!shadow-none"
:disabled="backup.isBackingUp.value || backup.externalBackupInProgress.value"
@click="backup.startBackup()"
:disabled="
!canManageBackups || backup.isBackingUp.value || backup.externalBackupInProgress.value
"
@click="startBackup"
>
<SpinnerIcon v-if="backup.isBackingUp.value" class="size-5 animate-spin" />
<PlusIcon v-else class="size-5" />
@@ -55,10 +59,13 @@
<script setup lang="ts">
import { CheckCircleIcon, PlusIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
import { watch } from 'vue'
import { computed, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { hasServerPermission } from '#ui/composables/server-permissions'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { useInlineBackup } from '../../composables/use-inline-backup'
@@ -73,9 +80,25 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const serverCtx = injectModrinthServerContext(null)
const canManageBackups = computed(
() => !serverCtx || hasServerPermission(serverCtx.currentUserPermissions.value, 'BACKUPS'),
)
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
const backup = useInlineBackup(() => props.backupName)
function startBackup() {
if (
!canManageBackups.value ||
backup.externalBackupInProgress.value ||
backup.isBackingUp.value
) {
return
}
backup.startBackup()
}
watch(
() => backup.isBackingUp.value,
(backing) => {
@@ -36,7 +36,8 @@ interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
busy?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
}
@@ -45,7 +46,8 @@ const props = withDefaults(defineProps<Props>(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
busy: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
switchVersion: undefined,
})
@@ -54,6 +56,7 @@ const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
'bulk:enable': [items: ContentItem[]]
'bulk:disable': [items: ContentItem[]]
hide: []
}>()
const messages = defineMessages({
@@ -250,12 +253,16 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
installing: item.installing === true,
toggleDisabled: props.actionDisabled,
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
disabled: props.busy || disabledIds.value.has(item.file_name) || item.installing === true,
disabled:
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
overflowOptions: [
...(props.switchVersion
? [
@@ -286,20 +293,20 @@ function getTypeIcon(type: string) {
}
function handleEnabledChange(fileName: string, value: boolean) {
if (props.busy) return
if (props.actionDisabled) return
const item = items.value.find((i) => i.file_name === fileName)
if (!item) return
emit('update:enabled', item, value)
}
function bulkEnable() {
if (props.busy) return
if (props.actionDisabled) return
emit('bulk:enable', [...selectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
if (props.busy) return
if (props.actionDisabled) return
emit('bulk:disable', [...selectedItems.value])
selectedIds.value = []
}
@@ -326,6 +333,10 @@ function hide() {
modal.value?.hide()
}
function handleHide() {
emit('hide')
}
function getState(): ModpackContentModalState | null {
if (!items.value.length) return null
return {
@@ -383,6 +394,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
:on-hide="handleHide"
no-padding
>
<template #title>
@@ -558,7 +570,8 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<ContentSelectionBar
v-if="props.enableToggle"
:selected-items="selectedItems"
:is-bulk-operating="props.busy"
:is-busy="props.actionDisabled"
:busy-tooltip="props.actionDisabledTooltip"
style="--left-bar-width: 0px; --right-bar-width: 0px"
@clear="selectedIds = []"
@enable="bulkEnable"
@@ -272,6 +272,9 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
id,
disabled:
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
toggleDisabled: ctx.isBusy.value,
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
hasUpdate: item.has_update,
isClientOnly:
@@ -321,7 +324,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -331,7 +334,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
function showBulkDeleteModal(event?: MouseEvent) {
pendingDeletionItems.value = [...selectedItems.value]
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -339,6 +342,7 @@ function showBulkDeleteModal(event?: MouseEvent) {
}
async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
if (itemsToDelete.length === 0) return
@@ -383,6 +387,7 @@ async function confirmDelete() {
}
async function handleToggleEnabledById(id: string, _value: boolean) {
if (ctx.isBusy.value) return
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
markChanging(id)
@@ -394,6 +399,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
}
async function bulkEnable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => !item.enabled)
if (items.length === 0) return
if (ctx.bulkEnableItems) {
@@ -414,6 +420,7 @@ async function bulkEnable() {
}
async function bulkDisable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => item.enabled)
if (items.length === 0) return
if (ctx.bulkDisableItems) {
@@ -455,7 +462,7 @@ function promptUpdateAll(event?: MouseEvent) {
const items = ctx.items.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -467,7 +474,7 @@ function promptUpdateSelected(event?: MouseEvent) {
const items = selectedItems.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -475,6 +482,7 @@ function promptUpdateSelected(event?: MouseEvent) {
}
async function confirmBulkUpdate() {
if (ctx.isBusy.value) return
const items = pendingBulkUpdateItems.value
if (items.length === 0 || !hasBulkUpdateSupport.value) return
@@ -525,12 +533,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="
ctx.modpack.value.disabledText ??
ctx.busyMessage?.value ??
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
"
:disabled="ctx.modpack.value.disabled"
:disabled-text="ctx.modpack.value.disabledText"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
@@ -677,14 +681,18 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
color-fill="text"
hover-color-fill="background"
>
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
<button
v-tooltip="formatMessage(messages.updateAll)"
:disabled="isBulkOperating"
@click="promptUpdateAll"
>
<DownloadIcon />
{{ formatMessage(messages.updateAll) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
<button :disabled="refreshing" @click="handleRefresh">
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
@@ -768,6 +776,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:selected-items="selectedItems"
:content-type-label="ctx.contentTypeLabel.value"
:is-busy="ctx.isBusy.value"
:busy-tooltip="ctx.busyMessage?.value"
:is-bulk-operating="isBulkOperating"
:bulk-operation="bulkOperation"
:bulk-progress="bulkProgress"
@@ -789,7 +798,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
>
<button
v-tooltip="formatMessage(commonMessages.updateButton)"
:disabled="ctx.isBusy.value"
@click="promptUpdateSelected"
>
<DownloadIcon />
@@ -852,7 +860,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
>
<button
v-tooltip="formatMessage(commonMessages.deleteLabel)"
:disabled="ctx.isBusy.value"
@click="showBulkDeleteModal"
>
<TrashIcon />
@@ -868,6 +875,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ConfirmBulkUpdateModal
@@ -875,6 +884,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
ref="confirmBulkUpdateModal"
:count="pendingBulkUpdateItems.length"
:server="ctx.deletionContext === 'server'"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@update="confirmBulkUpdate"
/>
<ConfirmUnlinkModal
@@ -882,6 +893,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
ref="confirmUnlinkModal"
:server="ctx.deletionContext === 'server'"
:backup-tip="ctx.modpack.value?.project.title"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@unlink="ctx.unlinkModpack!()"
/>
@@ -32,6 +32,9 @@ export interface ContentCardTableItem {
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
disabledTooltip?: string | null
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
installing?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
@@ -10,6 +10,7 @@
<ButtonStyled type="transparent" circular>
<button
v-tooltip="formatMessage(messages.toggleReplace)"
:disabled="props.readonly"
:aria-label="formatMessage(messages.toggleReplace)"
@click="toggleReplace"
>
@@ -88,6 +89,7 @@
type="search"
size="small"
autocomplete="off"
:disabled="props.readonly"
:placeholder="formatMessage(messages.replaceInFile)"
wrapper-class="w-44"
/>
@@ -95,7 +97,7 @@
<ButtonStyled type="outlined">
<button
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
:disabled="findMatchCount === 0"
:disabled="props.readonly || findMatchCount === 0"
@click="emit('replace', replaceQuery)"
>
{{ formatMessage(messages.replace) }}
@@ -104,7 +106,7 @@
<ButtonStyled type="outlined">
<button
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
:disabled="findMatchCount === 0"
:disabled="props.readonly || findMatchCount === 0"
@click="emit('replaceAll', replaceQuery)"
>
{{ formatMessage(messages.replaceAll) }}
@@ -129,6 +131,7 @@ const props = defineProps<{
findMatchCount: number
currentFindMatch: number
isEditingImage: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -193,6 +196,7 @@ const findInputRef = ref<{ focus: () => void } | null>(null)
const replaceInputRef = ref<{ focus: () => void } | null>(null)
function toggleReplace() {
if (props.readonly) return
isReplaceOpen.value = !isReplaceOpen.value
if (isReplaceOpen.value) {
nextTick(() => replaceInputRef.value?.focus())
@@ -204,6 +208,7 @@ function focusFindInput() {
}
function openReplace() {
if (props.readonly) return
isReplaceOpen.value = true
nextTick(() => replaceInputRef.value?.focus())
}
@@ -8,6 +8,7 @@
v-model:is-find-open="isFindOpen"
v-model:find-query="inFileFindQuery"
:is-editing-image="isEditingImage"
:readonly="isEditorReadOnly"
:find-match-count="findMatchCount"
:current-find-match="currentFindMatch"
@find-next="findNext"
@@ -22,6 +23,7 @@
v-model:value="fileContent"
:lang="editorLanguage"
theme="modrinth"
:readonly="isEditorReadOnly"
:print-margin="false"
:style="{ height: editorHeight, fontSize: '0.875rem' }"
class="ace-modrinth rounded-[20px]"
@@ -144,6 +146,11 @@ const editorLanguage = computed(() => {
const ext = getFileExtension(props.file?.name ?? '')
return getEditorLanguage(ext)
})
const isEditorReadOnly = computed(() => ctx.isBusy?.value ?? false)
watch(isEditorReadOnly, (readOnly) => {
editorInstance.value?.setReadOnly(readOnly)
})
watch(
() => props.file,
@@ -206,6 +213,7 @@ function resetState() {
function onEditorInit(editor: Ace.Editor) {
editorInstance.value = editor
editor.setReadOnly(isEditorReadOnly.value)
editor.commands.addCommand({
name: 'save',
@@ -223,6 +231,7 @@ function onEditorInit(editor: Ace.Editor) {
name: 'replace',
bindKey: { win: 'Ctrl-H', mac: 'Command-Option-F' },
exec: () => {
if (isEditorReadOnly.value) return
isFindOpen.value = true
nextTick(() => findReplaceRef.value?.openReplace())
},
@@ -231,6 +240,7 @@ function onEditorInit(editor: Ace.Editor) {
async function saveFileContent(exit: boolean = false) {
if (!props.file) return
if (ctx.isBusy?.value) return
try {
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
@@ -312,7 +322,7 @@ function closeFind() {
function replaceOne(query: string) {
const editor = editorInstance.value
if (!editor || findMatchCount.value === 0) return
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
editor.replace(query)
nextTick(() => {
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
@@ -323,7 +333,7 @@ function replaceOne(query: string) {
function replaceAllOccurrences(query: string) {
const editor = editorInstance.value
if (!editor || findMatchCount.value === 0) return
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
editor.replaceAll(query)
nextTick(() => {
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
@@ -37,6 +37,7 @@
</div>
<StyledInput
v-model="url"
v-tooltip="props.disabled ? props.disabledTooltip : undefined"
:icon="LinkIcon"
type="url"
:placeholder="
@@ -44,7 +45,7 @@
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
:disabled="submitted"
:disabled="submitted || props.disabled"
:error="touched && !!error"
autocomplete="off"
@focus="touched = true"
@@ -74,8 +75,8 @@
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="error"
:disabled="submitted || !!error || backupInProgress"
v-tooltip="submitTooltip"
:disabled="submitDisabled"
type="submit"
@click="handleSubmit"
>
@@ -118,6 +119,17 @@ const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
disabled?: boolean
disabledTooltip?: string
}>(),
{
disabled: false,
disabledTooltip: undefined,
},
)
const messages = defineMessages({
cfHeader: {
id: 'files.zip-url-modal.cf-header',
@@ -239,9 +251,17 @@ const error = computed(() => {
return ''
})
const submitDisabled = computed(
() => submitted.value || props.disabled || !!error.value || backupInProgress.value,
)
const submitTooltip = computed(() => {
if (props.disabled) return props.disabledTooltip
return error.value || undefined
})
const handleSubmit = async () => {
touched.value = true
if (error.value) return
if (submitDisabled.value) return
submitted.value = true
try {
@@ -270,6 +290,8 @@ const handleSubmit = async () => {
}
const show = (isCf: boolean) => {
if (props.disabled) return
cf.value = isCf
url.value = ''
submitted.value = false
@@ -3,7 +3,12 @@
<FileUnsavedChangesModal ref="unsavedChangesModal" />
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
<FileUploadZipUrlModal v-if="ctx.showInstallFromUrl" ref="uploadZipUrlModal" />
<FileUploadZipUrlModal
v-if="ctx.showInstallFromUrl"
ref="uploadZipUrlModal"
:disabled="isBusy"
:disabled-tooltip="busyTooltip"
/>
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
<FileMoveItemModal
ref="moveItemModal"
@@ -156,7 +161,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="fileEditorRef?.saveFileContent(false)">
<button
v-tooltip="isBusy ? busyTooltip : undefined"
:disabled="isBusy"
@click="fileEditorRef?.saveFileContent(false)"
>
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
</button>
</ButtonStyled>
@@ -370,6 +379,7 @@ async function confirmDiscardChanges(): Promise<boolean> {
if (!hasUnsavedChanges.value) return true
const result = await unsavedChangesModal.value?.prompt()
if (result === 'save') {
if (isBusy.value) return false
await fileEditorRef.value?.saveFileContent(false)
return true
}
@@ -412,10 +422,12 @@ async function handleEditorClose() {
// CRUD handlers
async function handleCreateNewItem(name: string) {
if (isBusy.value) return
await ctx.createItem(name, newItemType.value)
}
async function handleRenameItem(newName: string) {
if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -432,6 +444,7 @@ async function handleRenameItem(newName: string) {
}
async function handleMoveItem(destination: string) {
if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -450,6 +463,7 @@ async function handleMoveItem(destination: string) {
}
function handleDeleteItem() {
if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -513,6 +527,7 @@ async function handleExtractItem(item: { name: string; type: string; path: strin
}
async function handleExtractConfirm(path: string) {
if (isBusy.value) return
if (!ctx.extractFile) return
try {
await ctx.extractFile(path, true, false)
@@ -2,6 +2,7 @@ import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { formatLoaderLabel } from '#ui/utils/loaders'
import type { ContentUpdaterModal } from '../../content-tab'
@@ -20,6 +21,7 @@ export function useInstallationForm(
InstanceType<typeof IncompatibleContentModal> | null | undefined
>,
) {
const debug = useDebugLogger('InstallationSettingsForm')
const isEditing = ref(false)
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
const selectedGameVersion = ctx.editingGameVersionRef ?? ref(ctx.currentGameVersion.value)
@@ -77,13 +79,50 @@ export function useInstallationForm(
})
watch(selectedPlatform, () => {
debug('selectedPlatform watch:', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
selectedLoaderVersion.value = 0
})
watch(selectedGameVersion, () => {
debug('selectedGameVersion watch:', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
selectedLoaderVersion.value = 0
})
watch(
[isEditing, isSaving, isVerifying, pendingPreview, incompatibleContentVariant],
(value, oldValue) => {
debug('state watch:', {
oldValue,
value,
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
isValid: isValid.value,
hasChanges: hasChanges.value,
})
},
)
async function save() {
debug('save: start', {
isBusy: ctx.isBusy.value,
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
isValid: isValid.value,
hasChanges: hasChanges.value,
})
if (ctx.isBusy.value) {
debug('save: ignored busy')
return
}
isSaving.value = true
try {
const platformChanged = selectedPlatform.value !== ctx.currentPlatform.value
@@ -91,22 +130,37 @@ export function useInstallationForm(
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
if (platformChanged && ctx.disableAllContent) {
debug('save: platform changed, showing incompatible modal', {
currentPlatform: ctx.currentPlatform.value,
selectedPlatform: selectedPlatform.value,
})
isSaving.value = false
incompatibleContentVariant.value = 'loader-change'
await nextTick()
debug('save: incompatible modal ref before show', {
hasRef: !!incompatibleContentModalRef?.value,
})
incompatibleContentModalRef?.value?.show()
return
}
if (isModded && gameVersionChanged && ctx.disableIncompatibleContent) {
debug('save: game version changed, showing incompatible modal', {
currentGameVersion: ctx.currentGameVersion.value,
selectedGameVersion: selectedGameVersion.value,
})
isSaving.value = false
incompatibleContentVariant.value = 'game-version-change'
await nextTick()
debug('save: incompatible modal ref before show', {
hasRef: !!incompatibleContentModalRef?.value,
})
incompatibleContentModalRef?.value?.show()
return
}
if (ctx.previewSave && isModded && gameVersionChanged) {
debug('save: previewSave start')
isVerifying.value = true
abortController = new AbortController()
const loaderVersionId =
@@ -128,8 +182,15 @@ export function useInstallationForm(
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
debug('save: preview returned diffs, showing content diff modal', {
diffs: preview.diffs.length,
hasUnknownContent: preview.hasUnknownContent,
})
pendingPreview.value = preview
await nextTick()
debug('save: content diff modal ref before show', {
hasRef: !!contentDiffModalRef?.value,
})
contentDiffModalRef?.value?.show()
return
}
@@ -137,11 +198,17 @@ export function useInstallationForm(
await performSave()
} catch {
debug('save: caught error, resetting isSaving')
isSaving.value = false
}
}
async function performSave() {
debug('performSave: start', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
try {
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
@@ -150,12 +217,19 @@ export function useInstallationForm(
await ctx.save(selectedPlatform.value, selectedGameVersion.value, loaderVersionId)
if (ctx.afterSave) await ctx.afterSave()
isEditing.value = false
debug('performSave: success')
} finally {
isSaving.value = false
debug('performSave: finally', { isSaving: isSaving.value, isEditing: isEditing.value })
}
}
async function confirmLoaderChange() {
debug('confirmLoaderChange: start', { isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('confirmLoaderChange: ignored busy')
return
}
try {
if (ctx.disableAllContent) {
await ctx.disableAllContent()
@@ -169,6 +243,11 @@ export function useInstallationForm(
}
async function confirmAutoFix() {
debug('confirmAutoFix: start', { isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('confirmAutoFix: ignored busy')
return
}
try {
if (ctx.previewSave) {
isVerifying.value = true
@@ -192,10 +271,17 @@ export function useInstallationForm(
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
debug('confirmAutoFix: preview returned diffs', {
diffs: preview.diffs.length,
hasUnknownContent: preview.hasUnknownContent,
})
pendingPreview.value = preview
incompatibleContentVariant.value = null
await nextTick()
await nextTick()
debug('confirmAutoFix: content diff modal ref before show', {
hasRef: !!contentDiffModalRef?.value,
})
contentDiffModalRef?.value?.show()
return
}
@@ -210,6 +296,11 @@ export function useInstallationForm(
}
async function confirmDisableConflicts() {
debug('confirmDisableConflicts: start', { isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('confirmDisableConflicts: ignored busy')
return
}
try {
if (ctx.disableIncompatibleContent) {
await ctx.disableIncompatibleContent(selectedGameVersion.value)
@@ -239,6 +330,14 @@ export function useInstallationForm(
}
async function confirmSave() {
debug('confirmSave: start', {
isBusy: ctx.isBusy.value,
hasPendingPreview: !!pendingPreview.value,
})
if (ctx.isBusy.value) {
debug('confirmSave: ignored busy')
return
}
pendingPreview.value = null
try {
await performSave()
@@ -248,12 +347,28 @@ export function useInstallationForm(
}
function cancelPreview() {
debug('cancelPreview: start', {
hasPendingPreview: !!pendingPreview.value,
incompatibleContentVariant: incompatibleContentVariant.value,
isSaving: isSaving.value,
})
pendingPreview.value = null
incompatibleContentVariant.value = null
isSaving.value = false
debug('cancelPreview: done')
}
function cancelEditing() {
debug('cancelEditing: start', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
currentPlatform: ctx.currentPlatform.value,
currentGameVersion: ctx.currentGameVersion.value,
currentLoaderVersion: ctx.currentLoaderVersion.value,
isSaving: isSaving.value,
isVerifying: isVerifying.value,
})
abortController?.abort()
abortController = null
isVerifying.value = false
@@ -271,6 +386,13 @@ export function useInstallationForm(
0,
)
isEditing.value = false
debug('cancelEditing: done', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
entries: entries.length,
isEditing: isEditing.value,
})
}
// Modpack updater state
@@ -279,35 +401,66 @@ export function useInstallationForm(
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
watch([updatingModpack, loadingVersions, loadingChangelog], (value, oldValue) => {
debug('updater state watch:', {
oldValue,
value,
versions: updatingProjectVersions.value.length,
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
})
})
async function handleChangeModpackVersion() {
debug('handleChangeModpackVersion: start', {
isBusy: ctx.isBusy.value,
currentVersionId: ctx.updaterModalProps.value.currentVersionId,
hasUpdaterRef: !!updaterModalRef.value,
})
if (ctx.isBusy.value) {
debug('handleChangeModpackVersion: ignored busy')
return
}
updatingModpack.value = true
loadingChangelog.value = false
const cached = ctx.getCachedModpackVersions()
if (cached) {
debug('handleChangeModpackVersion: using cached versions', { count: cached.length })
updatingProjectVersions.value = [...cached].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
loadingVersions.value = false
} else {
debug('handleChangeModpackVersion: no cached versions')
updatingProjectVersions.value = []
loadingVersions.value = true
}
await nextTick()
debug('handleChangeModpackVersion: showing updater modal', {
hasUpdaterRef: !!updaterModalRef.value,
versions: updatingProjectVersions.value.length,
})
updaterModalRef.value?.show(ctx.updaterModalProps.value.currentVersionId || undefined)
if (!cached) {
try {
debug('handleChangeModpackVersion: fetching versions')
const versions = await ctx.fetchModpackVersions()
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
debug('handleChangeModpackVersion: fetched versions', { count: versions.length })
} catch {
// Error handled by context
} finally {
loadingVersions.value = false
debug('handleChangeModpackVersion: fetch done', {
loadingVersions: loadingVersions.value,
versions: updatingProjectVersions.value.length,
})
}
}
}
@@ -322,6 +475,10 @@ export function useInstallationForm(
}
async function handleUpdaterVersionSelect(version: Labrinth.Versions.v2.Version) {
debug('handleUpdaterVersionSelect:', {
versionId: version.id,
hasChangelog: !!version.changelog,
})
if (version.changelog) return
loadingChangelog.value = true
try {
@@ -333,6 +490,10 @@ export function useInstallationForm(
}
async function handleUpdaterVersionHover(version: Labrinth.Versions.v2.Version) {
debug('handleUpdaterVersionHover:', {
versionId: version.id,
hasChangelog: !!version.changelog,
})
if (version.changelog) return
try {
const full = await ctx.getVersionChangelog(version.id)
@@ -343,17 +504,30 @@ export function useInstallationForm(
}
function resetUpdateState() {
debug('resetUpdateState: start', {
updatingModpack: updatingModpack.value,
versions: updatingProjectVersions.value.length,
loadingVersions: loadingVersions.value,
loadingChangelog: loadingChangelog.value,
})
updatingModpack.value = false
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
debug('resetUpdateState: done')
}
async function handleUpdaterConfirm(version: Labrinth.Versions.v2.Version) {
debug('handleUpdaterConfirm: start', { versionId: version.id, isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('handleUpdaterConfirm: ignored busy')
return
}
try {
await ctx.onModpackVersionConfirm(version)
} finally {
resetUpdateState()
debug('handleUpdaterConfirm: done')
}
}
@@ -13,7 +13,7 @@ import {
UnlinkIcon,
XIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
@@ -23,6 +23,7 @@ import Chips from '#ui/components/base/Chips.vue'
import Combobox from '#ui/components/base/Combobox.vue'
import PaperChannelBadge from '#ui/components/base/PaperChannelBadge.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
@@ -41,6 +42,7 @@ import type { LoaderVersionEntry } from './types'
const { formatMessage } = useVIntl()
const ctx = injectInstallationSettings()
const debug = useDebugLogger('InstallationSettingsLayout')
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const repairModal = ref<InstanceType<typeof ConfirmRepairModal>>()
@@ -61,6 +63,67 @@ const form = useInstallationForm(
incompatibleContentModal,
)
function stateSnapshot() {
return {
loading: ctx.loading.value,
isLinked: ctx.isLinked.value,
isBusy: ctx.isBusy.value,
busyMessage: ctx.busyMessage?.value,
isEditing: form.isEditing.value,
isSaving: form.isSaving.value,
isVerifying: form.isVerifying.value,
selectedPlatform: form.selectedPlatform.value,
selectedGameVersion: form.selectedGameVersion.value,
selectedLoaderVersion: form.selectedLoaderVersion.value,
hasChanges: form.hasChanges.value,
isValid: form.isValid.value,
updatingModpack: form.updatingModpack.value,
loadingVersions: form.loadingVersions.value,
pendingPreview: !!form.pendingPreview.value,
incompatibleContentVariant: form.incompatibleContentVariant.value,
repairing: ctx.repairing?.value,
reinstalling: ctx.reinstalling?.value,
}
}
function modalRefsSnapshot() {
return {
confirmLeaveModal: !!confirmLeaveModal.value,
repairModal: !!repairModal.value,
reinstallModal: !!reinstallModal.value,
unlinkModal: !!unlinkModal.value,
contentUpdaterModal: !!contentUpdaterModal.value,
contentDiffModal: !!contentDiffModal.value,
incompatibleContentModal: !!incompatibleContentModal.value,
modpackUpdateModal: !!modpackUpdateModal.value,
}
}
onMounted(() => {
debug('mounted', stateSnapshot(), modalRefsSnapshot())
})
onUpdated(() => {
debug('updated', stateSnapshot(), modalRefsSnapshot())
})
watch(
[
() => ctx.loading.value,
() => ctx.isLinked.value,
() => ctx.isBusy.value,
() => form.isEditing.value,
() => form.isSaving.value,
() => form.isVerifying.value,
() => form.updatingModpack.value,
() => form.pendingPreview.value,
() => form.incompatibleContentVariant.value,
],
(value, oldValue) => {
debug('state watch:', { oldValue, value, snapshot: stateSnapshot() })
},
)
function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag'] | null {
if (form.selectedPlatform.value !== 'paper') return null
const entries = ctx.resolveLoaderVersions(
@@ -68,6 +131,13 @@ function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag']
form.selectedGameVersion.value,
)
const tag = entries[index]?.channelTag
debug('paperLoaderChannelTag:', {
index,
selectedPlatform: form.selectedPlatform.value,
selectedGameVersion: form.selectedGameVersion.value,
entries: entries.length,
tag,
})
return tag === 'ALPHA' || tag === 'BETA' ? tag : null
}
@@ -82,6 +152,7 @@ if (typeof window !== 'undefined') {
watch(
() => form.isSaving.value,
(saving) => {
debug('isSaving watch:', { saving })
if (saving) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
@@ -91,10 +162,12 @@ if (typeof window !== 'undefined') {
)
onBeforeUnmount(() => {
debug('beforeUnmount', stateSnapshot(), modalRefsSnapshot())
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
debug('beforeRouteLeave:', stateSnapshot())
if (form.isSaving.value) {
return (await confirmLeaveModal.value?.prompt()) ?? false
}
@@ -106,6 +179,14 @@ const disabledPlatforms = computed(() => {
if (!ctx.lockPlatform || ctx.currentPlatform.value === 'vanilla') return []
return ctx.availablePlatforms.filter((p) => p !== ctx.currentPlatform.value)
})
const platformDisabledItems = computed(() =>
ctx.isBusy.value ? ctx.availablePlatforms : disabledPlatforms.value,
)
const platformDisabledTooltip = computed(() =>
ctx.isBusy.value
? (ctx.busyMessage?.value ?? undefined)
: formatMessage(messages.platformLockTooltip),
)
const showModpackVersionActions = computed(() => {
const val = ctx.showModpackVersionActions
@@ -120,6 +201,17 @@ const isLocalFile = computed(() => {
})
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
debug('handleModpackUpdateRequest: start', {
versionId: version.id,
versionNumber: version.version_number,
shiftKey: event?.shiftKey,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (ctx.isBusy.value) {
debug('handleModpackUpdateRequest: ignored busy')
return
}
pendingUpdateVersion.value = version
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
@@ -132,41 +224,83 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
if (event?.shiftKey || !shouldShowWarning) {
debug('handleModpackUpdateRequest: confirming without warning', {
isUpdateDowngrade: isUpdateDowngrade.value,
shouldShowWarning,
})
handleModpackUpdateConfirm()
return
}
debug('handleModpackUpdateRequest: showing confirm modal', {
isUpdateDowngrade: isUpdateDowngrade.value,
shouldShowWarning,
refs: modalRefsSnapshot(),
})
modpackUpdateModal.value?.show()
}
function handleModpackUpdateConfirm() {
debug('handleModpackUpdateConfirm: start', {
pendingVersionId: pendingUpdateVersion.value?.id,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (ctx.isBusy.value) {
debug('handleModpackUpdateConfirm: ignored busy')
return
}
const version = pendingUpdateVersion.value
if (version) {
debug('handleModpackUpdateConfirm: hiding updater and closing settings')
contentUpdaterModal.value?.hide()
form.cancelEditing()
ctx.closeSettings?.()
form.handleUpdaterConfirm(version)
pendingUpdateVersion.value = null
debug('handleModpackUpdateConfirm: done')
}
}
function handleModpackUpdateCancel() {
debug('handleModpackUpdateCancel', {
pendingVersionId: pendingUpdateVersion.value?.id,
snapshot: stateSnapshot(),
})
pendingUpdateVersion.value = null
}
function handleRepair() {
debug('handleRepair: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
if (ctx.isBusy.value) {
debug('handleRepair: ignored busy')
return
}
form.cancelEditing()
ctx.repair()
debug('handleRepair: invoked ctx.repair')
}
function handleReinstall() {
debug('handleReinstall: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
if (ctx.isBusy.value) {
debug('handleReinstall: ignored busy')
return
}
form.cancelEditing()
ctx.reinstallModpack()
debug('handleReinstall: invoked ctx.reinstallModpack')
}
function handleUnlink() {
debug('handleUnlink: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
if (ctx.isBusy.value) {
debug('handleUnlink: ignored busy')
return
}
form.cancelEditing()
ctx.unlinkModpack()
debug('handleUnlink: invoked ctx.unlinkModpack')
}
const emit = defineEmits<{
@@ -174,9 +308,90 @@ const emit = defineEmits<{
}>()
function handleIncompatibleResetServer() {
debug('handleIncompatibleResetServer: start', { snapshot: stateSnapshot() })
if (ctx.isBusy.value) {
debug('handleIncompatibleResetServer: ignored busy')
return
}
form.cancelPreview()
form.cancelEditing()
emit('reset-server')
debug('handleIncompatibleResetServer: emitted reset-server')
}
function handleStartEditing() {
debug('handleStartEditing: before', stateSnapshot())
form.isEditing.value = true
nextTick(() => {
debug('handleStartEditing: after nextTick', stateSnapshot())
})
}
function handleCancelEditing() {
debug('handleCancelEditing: before', stateSnapshot())
form.cancelEditing()
nextTick(() => {
debug('handleCancelEditing: after nextTick', stateSnapshot())
})
}
function handleSave() {
debug('handleSave: before', stateSnapshot())
void form.save().finally(() => {
debug('handleSave: after promise', stateSnapshot())
})
}
function handleShowRepairModal() {
debug('handleShowRepairModal: before show', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
repairModal.value?.show()
nextTick(() => {
debug('handleShowRepairModal: after nextTick', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
})
}
function handleShowUnlinkModal(event: MouseEvent) {
debug('handleShowUnlinkModal: before', {
shiftKey: event.shiftKey,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (event.shiftKey) {
handleUnlink()
return
}
unlinkModal.value?.show()
nextTick(() => {
debug('handleShowUnlinkModal: after nextTick', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
})
}
function handleShowReinstallModal(event: MouseEvent) {
debug('handleShowReinstallModal: before', {
shiftKey: event.shiftKey,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (event.shiftKey) {
handleReinstall()
return
}
reinstallModal.value?.show()
nextTick(() => {
debug('handleShowReinstallModal: after nextTick', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
})
}
defineExpose({
@@ -413,6 +628,7 @@ const messages = defineMessages({
<div class="flex flex-wrap gap-2">
<ButtonStyled v-if="showModpackVersionActions">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.handleChangeModpackVersion()"
@@ -438,9 +654,10 @@ const messages = defineMessages({
<div>
<ButtonStyled color="orange">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="(e: MouseEvent) => (e.shiftKey ? handleUnlink() : unlinkModal?.show())"
@click="handleShowUnlinkModal"
>
<UnlinkIcon class="size-5" />
{{
@@ -473,11 +690,10 @@ const messages = defineMessages({
<div>
<ButtonStyled color="red">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="
(e: MouseEvent) => (e.shiftKey ? handleReinstall() : reinstallModal?.show())
"
@click="handleShowReinstallModal"
>
<SpinnerIcon v-if="ctx.reinstalling?.value" class="animate-spin" />
<DownloadIcon v-else class="size-5" />
@@ -512,9 +728,10 @@ const messages = defineMessages({
<div>
<ButtonStyled>
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
@click="handleShowRepairModal"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
@@ -555,8 +772,8 @@ const messages = defineMessages({
:items="ctx.availablePlatforms"
:format-label="formatLoaderLabel"
:capitalize="false"
:disabled-items="disabledPlatforms"
:disabled-tooltip="formatMessage(messages.platformLockTooltip)"
:disabled-items="platformDisabledItems"
:disabled-tooltip="platformDisabledTooltip"
:aria-label="formatMessage(messages.selectPlatformAriaLabel)"
/>
</div>
@@ -567,6 +784,7 @@ const messages = defineMessages({
</span>
<Combobox
v-model="form.selectedGameVersion.value"
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
:options="form.gameVersionOptions.value"
searchable
sync-with-selection
@@ -577,11 +795,14 @@ const messages = defineMessages({
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
:disabled="ctx.isBusy.value"
@option-hover="ctx.onGameVersionHover?.($event)"
>
<template v-if="form.hasSnapshots.value" #dropdown-footer>
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
:disabled="ctx.isBusy.value"
@mousedown.prevent
@click="form.showSnapshots.value = !form.showSnapshots.value"
>
@@ -610,6 +831,7 @@ const messages = defineMessages({
</span>
<Combobox
v-model="form.selectedLoaderVersion.value"
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
searchable
sync-with-selection
:placeholder="
@@ -627,6 +849,7 @@ const messages = defineMessages({
loader: form.formattedLoaderName.value,
})
"
:disabled="ctx.isBusy.value"
>
<template
v-if="form.selectedPlatform.value === 'paper'"
@@ -659,9 +882,15 @@ const messages = defineMessages({
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="!form.isValid.value || !form.hasChanges.value || form.isSaving.value"
@click="form.save()"
:disabled="
!form.isValid.value ||
!form.hasChanges.value ||
form.isSaving.value ||
ctx.isBusy.value
"
@click="handleSave"
>
<SpinnerIcon v-if="form.isSaving.value" class="animate-spin" />
<SaveIcon v-else />
@@ -675,7 +904,7 @@ const messages = defineMessages({
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="form.cancelEditing()">
<button @click="handleCancelEditing">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
@@ -702,9 +931,10 @@ const messages = defineMessages({
<div class="flex flex-wrap gap-2">
<ButtonStyled color="orange">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.isEditing.value = true"
@click="handleStartEditing"
>
<PencilIcon class="size-5" />
{{ formatMessage(commonMessages.editButton) }}
@@ -736,9 +966,10 @@ const messages = defineMessages({
<div>
<ButtonStyled>
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
@click="handleShowRepairModal"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
@@ -16,6 +16,7 @@ export interface InstallationSettingsContext {
installationInfo: ComputedRef<InstallationInfoRow[]>
isLinked: ComputedRef<boolean>
isBusy: Ref<boolean> | ComputedRef<boolean>
busyMessage?: Ref<string | null> | ComputedRef<string | null>
modpack: Ref<InstallationModpackData | null> | ComputedRef<InstallationModpackData | null>
@@ -8,10 +8,13 @@
<span class="text-lg font-semibold text-contrast">SFTP</span>
<ButtonStyled>
<a
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
v-tooltip="sftpActionTooltip"
class="!w-full sm:!w-auto"
:href="sftpUrl"
:class="{ 'opacity-60': !canWriteFiles }"
:href="canWriteFiles ? sftpUrl : undefined"
:aria-disabled="!canWriteFiles"
target="_blank"
@click="handleSftpLaunchClick"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
@@ -22,8 +25,9 @@
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
<span class="text-lg font-semibold text-contrast">Server Address</span>
<div
v-tooltip="'Copy SFTP server address'"
v-tooltip="sftpCopyTooltip('Copy SFTP server address')"
class="copy-field hover:bg-button-bg-hover"
:class="{ 'opacity-60': !canWriteFiles }"
@click="copyToClipboard('Server address', server?.sftp_host)"
>
<span class="cursor-pointer font-semibold text-primary">
@@ -37,8 +41,9 @@
<div class="flex w-full flex-col justify-center gap-2">
<span class="text-lg font-semibold text-contrast">Username</span>
<div
v-tooltip="'Copy SFTP username'"
v-tooltip="sftpCopyTooltip('Copy SFTP username')"
class="copy-field hover:bg-button-bg-hover"
:class="{ 'opacity-60': !canWriteFiles }"
@click="copyToClipboard('Username', server?.sftp_username)"
>
<div class="truncate font-semibold">
@@ -53,11 +58,12 @@
<span class="text-lg font-semibold text-contrast">Password</span>
<div
class="copy-field-has-button [&:hover:not(:has(button:hover))]:bg-button-bg-hover"
:class="{ 'opacity-60': !canWriteFiles }"
@click="copyToClipboard('Password', server?.sftp_password)"
>
<div class="flex items-center gap-1.5 h-full w-full">
<div
v-tooltip="'Copy SFTP Password'"
v-tooltip="sftpCopyTooltip('Copy SFTP Password')"
class="h-full flex justify-between grow items-center"
>
<div class="truncate font-semibold">
@@ -72,9 +78,16 @@
<ButtonStyled type="transparent" circular>
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
v-tooltip="
canWriteFiles
? showPassword
? 'Hide password'
: 'Show password'
: permissionDeniedMessage
"
class="hover:bg-button-bg-hover grid h-10 w-10 place-content-center rounded-lg"
@click.stop="showPassword = !showPassword"
:disabled="!canWriteFiles"
@click.stop="togglePasswordVisibility"
>
<!-- look into doing stop propagation here -->
<EyeIcon v-if="showPassword" class="h-5 w-5" />
@@ -96,7 +109,12 @@
</label>
<ButtonStyled v-if="startupCommand !== defaultStartupCommand" type="transparent">
<button
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
v-tooltip="advancedActionTooltip"
:disabled="
isStartupLoading ||
startupCommand === defaultStartupCommand ||
!canUseAdvancedSettings
"
class="relative !w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -109,10 +127,11 @@
<StyledInput
id="startup-command-field"
v-model="startupCommand"
v-tooltip="advancedActionTooltip"
multiline
resize="vertical"
input-class="font-mono field-sizing-content"
:disabled="isStartupLoading"
:disabled="isStartupLoading || !canUseAdvancedSettings"
/>
<div
v-if="isStartupLoading"
@@ -133,10 +152,11 @@
<Combobox
:id="'java-version-field'"
v-model="javaVersion"
v-tooltip="advancedActionTooltip"
name="java-version"
:options="displayedJavaVersions"
:display-value="javaVersionLabel ?? 'Java Version'"
:disabled="isStartupLoading"
:disabled="isStartupLoading || !canUseAdvancedSettings"
>
<template #dropdown-footer>
<button
@@ -169,10 +189,11 @@
<Combobox
:id="'runtime-field'"
v-model="jreVendor"
v-tooltip="advancedActionTooltip"
name="runtime"
:options="JRE_VENDORS"
:display-value="jreVendorLabel ?? 'Runtime'"
:disabled="isStartupLoading"
:disabled="isStartupLoading || !canUseAdvancedSettings"
/>
<div
v-if="isStartupLoading"
@@ -189,7 +210,7 @@
:is-visible="!!hasUnsavedChanges || isPending"
:server-id="serverId"
:is-updating="isPending"
:save="() => saveStartup()"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
@@ -210,6 +231,7 @@ import { computed, ref, watch } from 'vue'
import { ButtonStyled, Combobox, StyledInput } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -220,12 +242,29 @@ const { addNotification } = injectNotificationManager()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { canUseAdvancedSettings, canWriteFiles, permissionDeniedMessage } = useServerPermissions()
// SFTP state
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
)
const sftpActionTooltip = computed(() =>
canWriteFiles.value
? 'This button only works with compatible SFTP clients (e.g. WinSCP)'
: permissionDeniedMessage.value,
)
const sftpCopyTooltip = (label: string) =>
canWriteFiles.value ? label : permissionDeniedMessage.value
function handleSftpLaunchClick(event: MouseEvent) {
if (canWriteFiles.value) return
event.preventDefault()
}
const copyToClipboard = (name: string, textToCopy?: string) => {
if (!canWriteFiles.value) return
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
@@ -242,6 +281,11 @@ const { data: startupData, isLoading: isStartupLoading } = useQuery({
enabled: computed(() => worldId.value !== null),
})
function togglePasswordVisibility() {
if (!canWriteFiles.value) return
showPassword.value = !showPassword.value
}
const JAVA_VERSIONS = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
@@ -343,7 +387,7 @@ const hasUnsavedChanges = computed(
jreVendor.value !== savedJreVendor.value,
)
const { mutate: saveStartup, isPending } = useMutation({
const { mutate: saveStartupMutation, isPending } = useMutation({
mutationFn: () =>
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
startup_command: startupCommand.value || null,
@@ -369,11 +413,17 @@ const { mutate: saveStartup, isPending } = useMutation({
},
})
function saveStartup() {
if (!canUseAdvancedSettings.value) return
saveStartupMutation()
}
function resetStartup() {
syncFormFromData()
}
function resetToDefault() {
if (!canUseAdvancedSettings.value) return
startupCommand.value = defaultStartupCommand.value
}
</script>
@@ -13,8 +13,10 @@
<StyledInput
id="server-name-field"
v-model="serverName"
v-tooltip="advancedActionTooltip"
wrapper-class="w-full"
:maxlength="48"
:disabled="!canUseAdvancedSettings"
@keyup.enter="!serverName && saveGeneral"
/>
<span>This name is only visible on Modrinth.</span>
@@ -39,9 +41,11 @@
>
<input
id="server-subdomain"
v-tooltip="advancedActionTooltip"
:value="serverSubdomain"
placeholder="Enter subdomain..."
:maxlength="32"
:disabled="!canUseAdvancedSettings"
class="absolute left-px inset-0 bg-transparent !p-0 text-base font-medium text-primary !shadow-none transition-colors placeholder:text-secondary focus:text-contrast"
autocomplete="off"
@input="serverSubdomain = ($event.target as HTMLInputElement).value"
@@ -68,7 +72,11 @@
</div>
</div>
<EditServerIcon v-if="!data.is_medal" />
<EditServerIcon
v-if="!data.is_medal"
:can-edit="canWriteFiles"
:permission-denied-message="permissionDeniedMessage"
/>
</div>
<!-- preferences -->
@@ -145,6 +153,7 @@ import { computed, ref, watch } from 'vue'
import { CopyCode, StyledInput, Toggle } from '#ui/components'
import EditServerIcon from '#ui/components/servers/edit-server-icon/EditServerIcon.vue'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -157,6 +166,10 @@ const client = injectModrinthClient()
const { server: data, serverId, busyReasons } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
const queryClient = useQueryClient()
const { canUseAdvancedSettings, canWriteFiles, permissionDeniedMessage } = useServerPermissions()
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
)
const serverName = ref(data.value?.name)
const serverSubdomain = ref(data.value?.net?.domain ?? '')
@@ -176,12 +189,6 @@ const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidC
const isUpdating = ref(false)
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0)
watch(serverName, (newValue, oldValue) => {
if (!(newValue?.length ?? 0)) {
serverName.value = oldValue
}
})
// Preferences
const preferences = {
hideSubdomainLabel: {
@@ -334,15 +341,21 @@ const infoProperties = computed<InfoProperty[]>(() => [
])
// Unsaved changes tracking (API fields + preferences)
const hasUnsavedChanges = computed(
const hasServerSettingsChanges = computed(
() =>
(serverName.value && serverName.value !== data.value?.name) ||
serverSubdomain.value !== data.value?.net?.domain ||
JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value),
serverSubdomain.value !== data.value?.net?.domain,
)
const hasPreferenceChanges = computed(
() => JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value),
)
const hasUnsavedChanges = computed(
() => hasServerSettingsChanges.value || hasPreferenceChanges.value,
)
const saveGeneral = async () => {
if (!isValidServerName.value || !isValidSubdomain.value) return
if (hasServerSettingsChanges.value && !canUseAdvancedSettings.value) return
try {
isUpdating.value = true
@@ -12,7 +12,10 @@
</div>
</Teleport>
<InstallationSettingsLayout ref="installationSettingsLayout" @reset-server="setupModal?.show()">
<InstallationSettingsLayout
ref="installationSettingsLayout"
@reset-server="showResetServerModal"
>
<template #extra>
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
@@ -20,7 +23,12 @@
}}</span>
<div>
<ButtonStyled color="red">
<button class="!shadow-none" :disabled="isInstalling" @click="setupModal?.show()">
<button
v-tooltip="resetServerDisabledTooltip"
class="!shadow-none"
:disabled="resetServerDisabled"
@click="showResetServerModal"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.resetServerButton) }}
</button>
@@ -53,9 +61,10 @@
<div>
<ButtonStyled color="red">
<button
v-tooltip="supportResetToOnboardingTooltip"
class="!shadow-none"
:disabled="!worldId || isResettingToOnboarding"
@click="resetToOnboardingModal?.show()"
:disabled="supportResetToOnboardingDisabled"
@click="showResetToOnboardingModal"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.resetToOnboardingButton) }}
@@ -88,6 +97,7 @@ import {
UploadProgressModal,
useDebugLogger,
useModrinthServersConsole,
useServerPermissions,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -105,6 +115,7 @@ const { formatMessage } = useVIntl()
const serverSettings = injectServerSettings()
const filePicker = injectFilePicker()
const modrinthServersConsole = useModrinthServersConsole()
const { canSetup, canResetServer, permissionDeniedMessage } = useServerPermissions()
const uploadProgressModal =
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
@@ -204,9 +215,24 @@ const isInstalling = computed(() => {
)
return val
})
const setupActionDisabled = computed(() => !canSetup.value || isInstalling.value)
const setupActionDisabledMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : null
})
const resetServerDisabled = computed(() => !canResetServer.value || isInstalling.value)
const resetServerDisabledTooltip = computed(() => {
if (!canResetServer.value) return permissionDeniedMessage.value
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
function showResetServerModal() {
if (resetServerDisabled.value) return
setupModal.value?.show()
}
async function invalidateServerState() {
debug('invalidateServerState: starting')
await Promise.all([
@@ -245,6 +271,17 @@ const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
const editingGameVersion = ref(server.value?.mc_version ?? '')
const resetToOnboardingModal = ref<InstanceType<typeof ConfirmModal>>()
const isResettingToOnboarding = ref(false)
const supportResetToOnboardingDisabled = computed(
() => !worldId.value || isResettingToOnboarding.value || !canResetServer.value,
)
const supportResetToOnboardingTooltip = computed(() =>
!canResetServer.value ? permissionDeniedMessage.value : undefined,
)
function showResetToOnboardingModal() {
if (supportResetToOnboardingDisabled.value) return
resetToOnboardingModal.value?.show()
}
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
@@ -396,7 +433,8 @@ provideInstallationSettings({
debug('isLinked:', val, 'modpack:', modpackProjectId.value)
return val
}),
isBusy: isInstalling,
isBusy: setupActionDisabled,
busyMessage: setupActionDisabledMessage,
modpack: computed(() => {
if (!modpack.value) return null
const isLocal = modpack.value.spec.platform === 'local_file'
@@ -499,6 +537,7 @@ provideInstallationSettings({
},
async save(platform, gameVersion, loaderVersionId) {
if (setupActionDisabled.value) return
debug('save: called with', { platform, gameVersion, loaderVersionId })
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
const platformChanged = platform !== currentPlatform
@@ -548,6 +587,7 @@ provideInstallationSettings({
},
async repair() {
if (setupActionDisabled.value) return
debug('repair: called')
try {
await client.archon.content_v1.repair(serverId, worldId.value!)
@@ -568,6 +608,7 @@ provideInstallationSettings({
},
async reinstallModpack() {
if (setupActionDisabled.value) return
if (!modpack.value) return
if (modpack.value.spec.platform === 'local_file') {
debug('reinstallModpack: local file, opening file picker')
@@ -624,6 +665,7 @@ provideInstallationSettings({
},
async unlinkModpack() {
if (setupActionDisabled.value) return
debug('unlinkModpack: called')
const previousData = addonsQuery.data.value
if (previousData) {
@@ -695,6 +737,7 @@ provideInstallationSettings({
},
async onModpackVersionConfirm(version) {
if (setupActionDisabled.value) return
if (!modpackProjectId.value) return
debug('onModpackVersionConfirm: called, version:', version.id)
debug('onModpackVersionConfirm: emitting reinstall before API call')
@@ -741,6 +784,7 @@ provideInstallationSettings({
hideLoaderVersion: false,
async disableAllContent() {
if (setupActionDisabled.value) return
debug('disableAllContent: fetching all addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const items = (addons.addons ?? [])
@@ -754,6 +798,7 @@ provideInstallationSettings({
},
async disableIncompatibleContent(targetGameVersion) {
if (setupActionDisabled.value) return
debug('disableIncompatibleContent: fetching addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const activeAddons = (addons.addons ?? []).filter((a) => !a.disabled)
@@ -785,6 +830,7 @@ provideInstallationSettings({
},
async saveWithoutAutoFix(platform, gameVersion, loaderVersionId) {
if (setupActionDisabled.value) return
debug('saveWithoutAutoFix: called with', { platform, gameVersion, loaderVersionId })
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
@@ -816,6 +862,7 @@ provideInstallationSettings({
},
async previewSave(_platform, gameVersion, _loaderVersionId, signal) {
if (setupActionDisabled.value) return null
const result = await client.archon.content_v1.getUpdateGameVersionPreview(
serverId,
worldId.value!,
@@ -855,6 +902,7 @@ watch(
)
function onReinstall(event?: unknown) {
if (resetServerDisabled.value) return
installationSettingsLayout.value?.cancelEditing()
modrinthServersConsole.clear()
queryClient.removeQueries({ queryKey: ['servers', 'ws-state', serverId] })
@@ -863,6 +911,7 @@ function onReinstall(event?: unknown) {
}
function onBrowseModpacks() {
if (resetServerDisabled.value) return
debug('onBrowseModpacks: navigating to modpack discovery')
serverSettings.browseModpacks({
serverId,
@@ -872,7 +921,7 @@ function onBrowseModpacks() {
}
async function confirmResetToOnboarding() {
if (!worldId.value) return
if (supportResetToOnboardingDisabled.value || !worldId.value) return
try {
isResettingToOnboarding.value = true
@@ -9,8 +9,10 @@
id="edit-allocation-name"
ref="editAllocationInput"
v-model="editAllocationName"
v-tooltip="advancedActionTooltip"
wrapper-class="w-full"
:maxlength="32"
:disabled="!canUseAdvancedSettings"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-end gap-2.5">
@@ -18,7 +20,11 @@
<button @click="editAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!editAllocationName || creatingAllocation" type="submit">
<button
v-tooltip="advancedActionTooltip"
:disabled="!editAllocationName || creatingAllocation || !canUseAdvancedSettings"
type="submit"
>
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
@@ -70,15 +76,17 @@
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
<StyledInput
v-model="createAllocationName"
v-tooltip="advancedActionTooltip"
wrapper-class="grow max-w-[400px]"
:maxlength="32"
:disabled="!canUseAdvancedSettings"
placeholder="e.g. Secondary allocation"
/>
<ButtonStyled color="brand">
<button
v-tooltip="!createAllocationName ? 'Enter a name to create an allocation' : ''"
:disabled="!createAllocationName || creatingAllocation"
v-tooltip="createAllocationTooltip"
:disabled="!createAllocationName || creatingAllocation || !canUseAdvancedSettings"
@click="addNewAllocation"
>
<PlusIcon />
@@ -104,12 +112,20 @@
</ButtonStyled>
<template v-if="!row.primary">
<ButtonStyled type="transparent" circular>
<button @click="showEditAllocationModal(row.port)">
<button
v-tooltip="advancedActionTooltip"
:disabled="!canUseAdvancedSettings"
@click="showEditAllocationModal(row.port)"
>
<PencilIcon />
</button>
</ButtonStyled>
<ButtonStyled type="outlined" circular color="red">
<button @click="showConfirmDeleteModal(row.port)">
<button
v-tooltip="advancedActionTooltip"
:disabled="!canUseAdvancedSettings"
@click="showConfirmDeleteModal(row.port)"
>
<TrashIcon />
</button>
</ButtonStyled>
@@ -209,6 +225,7 @@ import { computed, nextTick, ref } from 'vue'
import { ButtonStyled, ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components'
import type { TableColumn } from '#ui/components/base'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -219,6 +236,7 @@ const { addNotification } = injectNotificationManager()
const { server, serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { canUseAdvancedSettings, permissionDeniedMessage } = useServerPermissions()
const data = server
@@ -271,8 +289,17 @@ const editAllocationName = ref('')
const newAllocationPort = ref(0)
const allocationToDelete = ref<number | null>(null)
const creatingAllocation = ref(false)
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
)
const createAllocationTooltip = computed(() => {
if (!canUseAdvancedSettings.value) return permissionDeniedMessage.value
if (!createAllocationName.value) return 'Enter a name to create an allocation'
return undefined
})
const addNewAllocation = async () => {
if (!canUseAdvancedSettings.value) return
if (!createAllocationName.value) return
creatingAllocation.value = true
@@ -295,6 +322,7 @@ const addNewAllocation = async () => {
}
const showEditAllocationModal = (port: number) => {
if (!canUseAdvancedSettings.value) return
newAllocationPort.value = port
editAllocationName.value = allocations.value?.find((a) => a.port === port)?.name ?? ''
editAllocationModal.value?.show()
@@ -306,11 +334,13 @@ const showEditAllocationModal = (port: number) => {
}
const showConfirmDeleteModal = (port: number) => {
if (!canUseAdvancedSettings.value) return
allocationToDelete.value = port
confirmDeleteModal.value?.show()
}
const confirmDeleteAllocation = async () => {
if (!canUseAdvancedSettings.value) return
if (allocationToDelete.value === null) return
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
@@ -326,6 +356,7 @@ const confirmDeleteAllocation = async () => {
}
const editAllocation = async () => {
if (!canUseAdvancedSettings.value) return
if (!editAllocationName.value) return
creatingAllocation.value = true
@@ -56,6 +56,8 @@
v-model="combinedGamemode"
:items="gamemodeItems"
:format-label="capitalize"
:disabled-items="canUseAdvancedSettings ? [] : gamemodeItems"
:disabled-tooltip="permissionDeniedMessage"
/>
</div>
@@ -68,6 +70,8 @@
v-model="selectedDifficulty"
:items="difficultyItems"
:format-label="capitalize"
:disabled-items="canUseAdvancedSettings ? [] : difficultyItems"
:disabled-tooltip="permissionDeniedMessage"
/>
</div>
@@ -75,10 +79,12 @@
<span class="font-semibold text-contrast">Max players</span>
<StyledInput
id="server-property-max-players"
v-tooltip="advancedActionTooltip"
:model-value="liveProperties.max_players"
type="number"
placeholder="20"
wrapper-class="w-full max-w-[450px]"
:disabled="!canUseAdvancedSettings"
@update:model-value="liveProperties.max_players = String($event)"
/>
</div>
@@ -88,8 +94,10 @@
<StyledInput
id="server-property-motd"
v-model="liveProperties.motd"
v-tooltip="advancedActionTooltip"
placeholder="A Minecraft Server"
wrapper-class="w-full max-w-[450px]"
:disabled="!canUseAdvancedSettings"
/>
</div>
@@ -100,7 +108,9 @@
<span class="font-semibold text-contrast">Allow flight</span>
<Toggle
id="server-property-allow-flight"
v-tooltip="advancedActionTooltip"
:model-value="liveProperties.allow_flight === 'true'"
:disabled="!canUseAdvancedSettings"
@update:model-value="liveProperties.allow_flight = $event ? 'true' : 'false'"
/>
</div>
@@ -112,7 +122,9 @@
<span class="font-semibold text-contrast">Allow cheats</span>
<Toggle
id="server-property-allow-cheats"
v-tooltip="advancedActionTooltip"
:model-value="liveProperties.allow_cheats === 'true'"
:disabled="!canUseAdvancedSettings"
@update:model-value="liveProperties.allow_cheats = $event ? 'true' : 'false'"
/>
</div>
@@ -122,7 +134,12 @@
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable whitelist</span>
<Toggle id="server-property-whitelist" v-model="whitelistEnabled" />
<Toggle
id="server-property-whitelist"
v-model="whitelistEnabled"
v-tooltip="advancedActionTooltip"
:disabled="!canUseAdvancedSettings"
/>
</div>
<div
@@ -133,6 +150,8 @@
<Toggle
id="server-property-spawn-protection-toggle"
v-model="spawnProtectionEnabled"
v-tooltip="advancedActionTooltip"
:disabled="!canUseAdvancedSettings"
/>
</div>
@@ -143,10 +162,12 @@
<span class="font-semibold text-contrast">Protection radius</span>
<StyledInput
id="server-property-spawn-protection-radius"
v-tooltip="advancedActionTooltip"
:model-value="liveProperties.spawn_protection"
type="number"
wrapper-class="w-full sm:w-[100px]"
input-class="text-right"
:disabled="!canUseAdvancedSettings"
@update:model-value="liveProperties.spawn_protection = String($event)"
/>
</div>
@@ -188,8 +209,10 @@
>
<Toggle
:id="`server-property-${key}`"
v-tooltip="advancedActionTooltip"
:model-value="liveProperties[key] === 'true'"
:aria-labelledby="`property-label-${key}`"
:disabled="!canUseAdvancedSettings"
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
/>
</div>
@@ -199,11 +222,13 @@
>
<StyledInput
:id="`server-property-${key}`"
v-tooltip="advancedActionTooltip"
:model-value="liveProperties[key]"
type="number"
placeholder="Type here..."
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
:disabled="!canUseAdvancedSettings"
@update:model-value="liveProperties[key] = String($event)"
/>
</div>
@@ -211,9 +236,11 @@
<StyledInput
:id="`server-property-${key}`"
v-model="liveProperties[key]"
v-tooltip="advancedActionTooltip"
placeholder="Type here..."
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
:disabled="!canUseAdvancedSettings"
/>
</div>
</div>
@@ -253,7 +280,7 @@
:is-visible="hasUnsavedChanges || isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
restart
:restart="canUsePowerActions"
:save="
async () => {
await saveProperties()
@@ -273,6 +300,7 @@ import { computed, ref, watch } from 'vue'
import { Accordion, Admonition, AutoLink, Chips, StyledInput, Toggle } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import { injectServerSettings } from '#ui/layouts/shared/server-settings'
import {
injectModrinthClient,
@@ -284,6 +312,11 @@ const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const { canUseAdvancedSettings, canUsePowerActions, permissionDeniedMessage } =
useServerPermissions()
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
)
const filesTabLink = computed(
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
)
@@ -486,7 +519,7 @@ function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
return patch
}
const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
const { mutateAsync: savePropertiesMutation, isPending: isUpdating } = useMutation({
mutationFn: () =>
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
onSuccess: async () => {
@@ -507,6 +540,11 @@ const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
},
})
async function saveProperties() {
if (!canUseAdvancedSettings.value) return
await savePropertiesMutation()
}
function resetProperties() {
syncFormFromData()
}