mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 10:34:53 +00:00
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:
@@ -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')"
|
||||
|
||||
+14
-5
@@ -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')
|
||||
}
|
||||
|
||||
+18
-9
@@ -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')
|
||||
}
|
||||
|
||||
+8
-1
@@ -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')
|
||||
}
|
||||
|
||||
+17
@@ -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({
|
||||
|
||||
+34
-4
@@ -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({
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
+29
-6
@@ -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) => {
|
||||
|
||||
+20
-7
@@ -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)
|
||||
|
||||
+26
-4
@@ -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)
|
||||
|
||||
+174
@@ -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" />
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2 md:flex-row">
|
||||
<StyledInput
|
||||
v-model="memberSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchUsersPlaceholder, { count: members.length })"
|
||||
wrapper-class="min-w-0 flex-1"
|
||||
input-class="!h-10"
|
||||
clearable
|
||||
/>
|
||||
<div class="flex shrink-0 items-center gap-2 flex-wrap md:flex-nowrap">
|
||||
<Combobox
|
||||
v-model="roleFilter"
|
||||
:options="roleFilterOptions"
|
||||
:display-value="selectedRoleFilterLabel"
|
||||
trigger-class="min-w-[225px] !h-10 !min-h-10 !py-0"
|
||||
>
|
||||
<template #prefix>
|
||||
<FilterIcon class="size-5 text-secondary" aria-hidden="true" />
|
||||
</template>
|
||||
</Combobox>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="manageUsersActionTooltip"
|
||||
class="!h-10 w-full md:w-fit"
|
||||
:disabled="!canManageUsers"
|
||||
@click="grantAccessModal?.show($event)"
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.inviteFriends) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccessTable
|
||||
:members="filteredMembers"
|
||||
:roles="roleOptions"
|
||||
:can-manage-users="canManageUsers"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
@update-role="updateMemberRole"
|
||||
@resend-invite="resendInvite"
|
||||
@cancel-invite="requestCancelInvite"
|
||||
@remove-member="requestRemoveMember"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="m-0 text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.activityLogTitle) }}
|
||||
</span>
|
||||
<AuditLogTable
|
||||
v-model:sort-direction="auditLogSortDirection"
|
||||
v-model:timeframe-mode="auditLogTimeframeMode"
|
||||
v-model:timeframe-preset="auditLogTimeframePreset"
|
||||
v-model:timeframe-last-amount="auditLogTimeframeLastAmount"
|
||||
v-model:timeframe-last-unit="auditLogTimeframeLastUnit"
|
||||
v-model:timeframe-custom-start-date="auditLogTimeframeCustomStartDate"
|
||||
v-model:timeframe-custom-end-date="auditLogTimeframeCustomEndDate"
|
||||
:entries="auditEntries"
|
||||
:has-active-external-filters="hasActiveAuditLogFilters"
|
||||
:has-more="hasMoreActionLogEntries"
|
||||
:loading="isActionLogFiltering"
|
||||
:loading-more="isLoadingMoreActionLogEntries"
|
||||
:show-world-column="showAuditLogInstances"
|
||||
:suppress-row-transitions="isActionLogSortTransitioning"
|
||||
@load-more="loadMoreActionLogEntries"
|
||||
>
|
||||
<template #filters>
|
||||
<DropdownFilterBar
|
||||
v-model="auditLogFilters"
|
||||
:categories="auditLogFilterCategories"
|
||||
:add-label="formatMessage(messages.addFilter)"
|
||||
:clear-label="formatMessage(messages.clearFilters)"
|
||||
:empty-options-label="formatMessage(messages.emptyFilterOptions)"
|
||||
:empty-search-label="formatMessage(messages.emptyFilterSearch)"
|
||||
apply-immediately
|
||||
use-filter-icon
|
||||
checkbox-position="right"
|
||||
/>
|
||||
</template>
|
||||
</AuditLogTable>
|
||||
</div>
|
||||
|
||||
<GrantAccessModal
|
||||
ref="grantAccessModal"
|
||||
:members="members"
|
||||
:friend-ids="friendIds"
|
||||
:search-users="searchInviteUsers"
|
||||
:can-grant="canManageUsers"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
@grant="grantAccess"
|
||||
/>
|
||||
<RemoveAccessModal
|
||||
ref="removeMemberConfirmModal"
|
||||
:username="pendingRemovalMember?.user.username ?? ''"
|
||||
:avatar-url="pendingRemovalMember?.user.avatarUrl"
|
||||
:role="pendingRemovalMember?.role"
|
||||
:joined-at="pendingRemovalMember?.joinedAt"
|
||||
:pending="pendingRemovalMember?.pending"
|
||||
:should-cancel="shouldCancelInvite"
|
||||
:can-remove="canManageUsers"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
@remove="confirmAccessRemoval"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { FilterIcon, SearchIcon, UserPlusIcon } from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import {
|
||||
AccessTable,
|
||||
apiPermissionsToAccessRole,
|
||||
AuditLogTable,
|
||||
GrantAccessModal,
|
||||
type GrantServerAccessPayload,
|
||||
RemoveAccessModal,
|
||||
type ServerAccessInviteSuggestion,
|
||||
type ServerAccessMember,
|
||||
type ServerAccessRole,
|
||||
type ServerAccessRoleOption,
|
||||
} from '#ui/components/servers/access'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
import { useAccessAuditLog } from './audit-log'
|
||||
import { accessMessages } from './messages'
|
||||
|
||||
type RoleFilter = ServerAccessRole | 'all'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showAuditLogInstances?: boolean
|
||||
}>(),
|
||||
{
|
||||
showAuditLogInstances: false,
|
||||
},
|
||||
)
|
||||
const showAuditLogInstances = computed(() => props.showAuditLogInstances)
|
||||
|
||||
const INVITE_RESEND_COOLDOWN_SECONDS = 2 * 60
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, serverFull } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const grantAccessModal = ref<InstanceType<typeof GrantAccessModal> | null>(null)
|
||||
const removeMemberConfirmModal = ref<InstanceType<typeof RemoveAccessModal> | null>(null)
|
||||
const pendingRemovalMember = ref<ServerAccessMember | null>(null)
|
||||
const shouldCancelInvite = ref(false)
|
||||
const reinviteCooldownUntilByUserId = ref<Record<string, number | undefined>>({})
|
||||
const editorScopes = [
|
||||
'BASE_READ',
|
||||
'POWER_ACTIONS',
|
||||
'EXEC_COMMANDS',
|
||||
'FILES_WRITE',
|
||||
'SETUP',
|
||||
'BACKUPS',
|
||||
'ADVANCED',
|
||||
] as const
|
||||
const viewerScopes = ['BASE_READ', 'POWER_ACTIONS'] as const
|
||||
|
||||
const { canManageUsers, permissionDeniedMessage } = useServerPermissions()
|
||||
const manageUsersActionTooltip = computed(() =>
|
||||
canManageUsers.value ? undefined : permissionDeniedMessage.value,
|
||||
)
|
||||
|
||||
const messages = accessMessages
|
||||
|
||||
const roleOptions = computed<ServerAccessRoleOption[]>(() => [
|
||||
{
|
||||
value: 'owner',
|
||||
label: formatMessage(messages.ownerRole),
|
||||
description: formatMessage(messages.ownerDescription),
|
||||
},
|
||||
{
|
||||
value: 'editor',
|
||||
label: formatMessage(messages.editorRole),
|
||||
description: formatMessage(messages.editorDescription),
|
||||
},
|
||||
{
|
||||
value: 'viewer',
|
||||
label: formatMessage(messages.viewerRole),
|
||||
description: formatMessage(messages.viewerDescription),
|
||||
},
|
||||
])
|
||||
|
||||
const roleFilterOptions = computed<ComboboxOption<RoleFilter>[]>(() => [
|
||||
{ value: 'all', label: formatMessage(messages.allRoles) },
|
||||
...roleOptions.value.map((role) => ({
|
||||
value: role.value,
|
||||
label: role.label,
|
||||
})),
|
||||
])
|
||||
|
||||
const selectedRoleFilterLabel = computed(() =>
|
||||
formatMessage(messages.selectedRoleFilter, {
|
||||
role:
|
||||
roleFilterOptions.value.find((option) => option.value === roleFilter.value)?.label ??
|
||||
formatMessage(messages.allRoles),
|
||||
}),
|
||||
)
|
||||
|
||||
const serverUsersQueryKey = ['servers', 'users', 'v1', serverId]
|
||||
const serverUsersQuery = useQuery({
|
||||
queryKey: serverUsersQueryKey,
|
||||
queryFn: () => client.archon.server_users_v1.list(serverId),
|
||||
})
|
||||
|
||||
const friendsQueryKey = ['user', 'friends', 'v3']
|
||||
const friendsQuery = useQuery({
|
||||
queryKey: friendsQueryKey,
|
||||
queryFn: () => client.labrinth.friends_v3.list(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const friendIds = computed(() => getFriendRelationshipUserIds(friendsQuery.data.value ?? []))
|
||||
|
||||
const members = computed<ServerAccessMember[]>(() =>
|
||||
(serverUsersQuery.data.value ?? [])
|
||||
.map((serverUser) => {
|
||||
const userId = serverUser.user.id
|
||||
const username = serverUser.user.username || userId
|
||||
const role = apiPermissionsToAccessRole(serverUser.permissions)
|
||||
const nowReinviteAvailableAt = reinviteCooldownUntilByUserId.value[userId]
|
||||
const apiReinviteAvailableAt = getInviteResendAvailableAt(serverUser.last_invite_sent)
|
||||
const reinviteAvailableAt = [nowReinviteAvailableAt, apiReinviteAvailableAt].reduce(
|
||||
(candidate, current) =>
|
||||
candidate === undefined || (current !== undefined && current > candidate)
|
||||
? current
|
||||
: candidate,
|
||||
)
|
||||
|
||||
return {
|
||||
id: `${serverId}-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username,
|
||||
avatarUrl: serverUser.user.avatar_url || undefined,
|
||||
},
|
||||
role,
|
||||
joinedAt: serverUser.added_on ?? null,
|
||||
pending: !serverUser.added_on,
|
||||
inviteResendAvailableAt: reinviteAvailableAt
|
||||
? new Date(reinviteAvailableAt).toISOString()
|
||||
: undefined,
|
||||
isOwner: role === 'owner',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const ownerSort = Number(b.isOwner) - Number(a.isOwner)
|
||||
return ownerSort === 0 ? a.user.username.localeCompare(b.user.username) : ownerSort
|
||||
}),
|
||||
)
|
||||
|
||||
const memberSearch = ref('')
|
||||
const roleFilter = ref<RoleFilter>('all')
|
||||
const {
|
||||
auditEntries,
|
||||
auditLogFilterCategories,
|
||||
auditLogFilters,
|
||||
auditLogSortDirection,
|
||||
auditLogTimeframeCustomEndDate,
|
||||
auditLogTimeframeCustomStartDate,
|
||||
auditLogTimeframeLastAmount,
|
||||
auditLogTimeframeLastUnit,
|
||||
auditLogTimeframeMode,
|
||||
auditLogTimeframePreset,
|
||||
hasActiveAuditLogFilters,
|
||||
hasMoreActionLogEntries,
|
||||
invalidateActionLog,
|
||||
isActionLogFiltering,
|
||||
isActionLogSortTransitioning,
|
||||
isLoadingMoreActionLogEntries,
|
||||
loadMoreActionLogEntries,
|
||||
} = useAccessAuditLog({
|
||||
client,
|
||||
serverId,
|
||||
serverFull,
|
||||
showAuditLogInstances,
|
||||
addNotification,
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const normalizedSearch = memberSearch.value.trim().toLowerCase()
|
||||
return members.value.filter((member) => {
|
||||
if (roleFilter.value !== 'all' && member.role !== roleFilter.value) return false
|
||||
if (!normalizedSearch) return true
|
||||
|
||||
const roleLabel = formatRole(member.role)
|
||||
const pendingLabel = member.pending ? 'pending' : ''
|
||||
return [member.user.username, roleLabel, pendingLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedSearch),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function formatRole(role: ServerAccessRole) {
|
||||
return roleOptions.value.find((option) => option.value === role)?.label ?? role
|
||||
}
|
||||
|
||||
const hasShownLoadError = ref(false)
|
||||
|
||||
watch(
|
||||
() => serverUsersQuery.error.value,
|
||||
(serverUsersError) => {
|
||||
if (hasShownLoadError.value || !serverUsersError) return
|
||||
|
||||
hasShownLoadError.value = true
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.loadFailedTitle),
|
||||
text: formatErrorMessage(serverUsersError) ?? formatMessage(messages.loadFailedText),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function accessRoleToApiRole(
|
||||
role: Exclude<ServerAccessRole, 'owner'>,
|
||||
): Archon.ServerUsers.v1.AssignableServerUserRole {
|
||||
switch (role) {
|
||||
case 'editor':
|
||||
return 'Editor'
|
||||
case 'viewer':
|
||||
return 'Viewer'
|
||||
}
|
||||
}
|
||||
|
||||
function accessRoleToApiPermissions(role: Exclude<ServerAccessRole, 'owner'>) {
|
||||
switch (role) {
|
||||
case 'editor':
|
||||
return serializeUserScope(editorScopes)
|
||||
case 'viewer':
|
||||
return serializeUserScope(viewerScopes)
|
||||
}
|
||||
}
|
||||
|
||||
function serializeUserScope(scopes: readonly string[]): Archon.ServerUsers.v1.UserScope {
|
||||
return scopes.join(' | ')
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string | undefined {
|
||||
return error instanceof Error ? error.message : undefined
|
||||
}
|
||||
|
||||
function isSuppressedFriendRequestError(error: unknown) {
|
||||
return getErrorMessageParts(error).some((message) => {
|
||||
const normalizedMessage = message.toLowerCase()
|
||||
return (
|
||||
normalizedMessage.includes('you are already friends with this user') ||
|
||||
normalizedMessage.includes('you cannot add yourself as a friend') ||
|
||||
normalizedMessage.includes('you cannot accept your own friend request')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getErrorMessageParts(error: unknown): string[] {
|
||||
const errorMessages: string[] = []
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessages.push(error.message)
|
||||
}
|
||||
|
||||
if (!error || typeof error !== 'object') return errorMessages
|
||||
|
||||
const record = error as Record<string, unknown>
|
||||
pushErrorDescription(errorMessages, record.responseData)
|
||||
pushErrorDescription(errorMessages, record.v1Error)
|
||||
|
||||
return errorMessages
|
||||
}
|
||||
|
||||
function pushErrorDescription(errorMessages: string[], value: unknown) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (typeof record.description === 'string') {
|
||||
errorMessages.push(record.description)
|
||||
}
|
||||
}
|
||||
|
||||
async function invalidateServerUsers() {
|
||||
await queryClient.invalidateQueries({ queryKey: serverUsersQueryKey })
|
||||
}
|
||||
|
||||
function setCachedMemberRole(member: ServerAccessMember, role: Exclude<ServerAccessRole, 'owner'>) {
|
||||
const normalizedUserId = member.user.id.toLowerCase()
|
||||
const normalizedUsername = member.user.username.toLowerCase()
|
||||
|
||||
queryClient.setQueryData<Archon.ServerUsers.v1.ServerUser[]>(serverUsersQueryKey, (serverUsers) =>
|
||||
serverUsers?.map((serverUser) => {
|
||||
const isTargetUser =
|
||||
serverUser.user.id.toLowerCase() === normalizedUserId ||
|
||||
serverUser.user.username.toLowerCase() === normalizedUsername
|
||||
|
||||
return isTargetUser
|
||||
? { ...serverUser, permissions: accessRoleToApiPermissions(role) }
|
||||
: serverUser
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function findMemberByTarget(target: string) {
|
||||
const normalizedTarget = target.trim().toLowerCase()
|
||||
return members.value.find(
|
||||
(member) =>
|
||||
member.user.username.toLowerCase() === normalizedTarget ||
|
||||
member.user.id.toLowerCase() === normalizedTarget,
|
||||
)
|
||||
}
|
||||
|
||||
async function searchInviteUsers(query: string): Promise<ServerAccessInviteSuggestion[]> {
|
||||
const users = await client.labrinth.users_v3.search(query)
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveMemberUserId(member: ServerAccessMember): string {
|
||||
return member.user.id
|
||||
}
|
||||
|
||||
function getInviteResendAvailableAt(lastInviteSent: string | null | undefined): number | undefined {
|
||||
if (!lastInviteSent) return undefined
|
||||
const lastInviteSentAt = new Date(lastInviteSent).getTime()
|
||||
if (Number.isNaN(lastInviteSentAt)) return undefined
|
||||
return lastInviteSentAt + INVITE_RESEND_COOLDOWN_SECONDS * 1000
|
||||
}
|
||||
|
||||
function setReinviteCooldown(member: ServerAccessMember, cooldownSeconds: number | null) {
|
||||
if (!cooldownSeconds) {
|
||||
reinviteCooldownUntilByUserId.value[member.user.id] = undefined
|
||||
return
|
||||
}
|
||||
|
||||
reinviteCooldownUntilByUserId.value[member.user.id] = Date.now() + cooldownSeconds * 1000
|
||||
}
|
||||
|
||||
async function updateMemberRole(member: ServerAccessMember, role: ServerAccessRole) {
|
||||
if (!canManageUsers.value || member.isOwner || member.role === role || role === 'owner') return
|
||||
const previousRole = member.role
|
||||
if (previousRole === 'owner') return
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: serverUsersQueryKey })
|
||||
setCachedMemberRole(member, role)
|
||||
|
||||
try {
|
||||
const userId = await resolveMemberUserId(member)
|
||||
await client.archon.server_users_v1.update(serverId, userId, accessRoleToApiRole(role))
|
||||
} catch (error) {
|
||||
setCachedMemberRole(member, previousRole)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.roleUpdateFailedTitle),
|
||||
text: formatErrorMessage(error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await invalidateServerUsers()
|
||||
await invalidateActionLog()
|
||||
}
|
||||
|
||||
async function resendInvite(member: ServerAccessMember) {
|
||||
if (!canManageUsers.value || !member.pending || member.role === 'owner') return
|
||||
|
||||
try {
|
||||
const result = await client.archon.server_users_v1.reinvite(serverId, member.user.id)
|
||||
setReinviteCooldown(member, result.cooldown_seconds)
|
||||
|
||||
if (!result.sent) return
|
||||
|
||||
await invalidateServerUsers()
|
||||
await invalidateActionLog()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.inviteResentTitle),
|
||||
text: formatMessage(messages.inviteResentText, {
|
||||
target: member.user.username,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.inviteFailedTitle),
|
||||
text: formatErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelInvite(member: ServerAccessMember) {
|
||||
await removeMemberAccess(member, true)
|
||||
}
|
||||
|
||||
function requestRemoveMember(member: ServerAccessMember) {
|
||||
if (!canManageUsers.value) return
|
||||
pendingRemovalMember.value = member
|
||||
shouldCancelInvite.value = false
|
||||
removeMemberConfirmModal.value?.show()
|
||||
}
|
||||
|
||||
function requestCancelInvite(member: ServerAccessMember) {
|
||||
if (!canManageUsers.value) return
|
||||
pendingRemovalMember.value = member
|
||||
shouldCancelInvite.value = true
|
||||
removeMemberConfirmModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmAccessRemoval() {
|
||||
const member = pendingRemovalMember.value
|
||||
const shouldCancel = shouldCancelInvite.value
|
||||
pendingRemovalMember.value = null
|
||||
shouldCancelInvite.value = false
|
||||
if (!member) return
|
||||
if (!canManageUsers.value) return
|
||||
|
||||
if (shouldCancel) {
|
||||
await cancelInvite(member)
|
||||
return
|
||||
}
|
||||
|
||||
await removeMember(member)
|
||||
}
|
||||
|
||||
async function removeMember(member: ServerAccessMember) {
|
||||
await removeMemberAccess(member, false)
|
||||
}
|
||||
|
||||
async function removeMemberAccess(member: ServerAccessMember, shouldCancel: boolean) {
|
||||
if (!canManageUsers.value) return
|
||||
|
||||
try {
|
||||
const userId = await resolveMemberUserId(member)
|
||||
await client.archon.server_users_v1.delete(serverId, userId)
|
||||
await invalidateServerUsers()
|
||||
await invalidateActionLog()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(
|
||||
shouldCancel ? messages.inviteCancelledTitle : messages.memberRemovedTitle,
|
||||
),
|
||||
text: formatMessage(
|
||||
shouldCancel ? messages.inviteCancelledText : messages.memberRemovedText,
|
||||
{
|
||||
target: member.user.username,
|
||||
},
|
||||
),
|
||||
})
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.removeFailedTitle),
|
||||
text: formatErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function grantAccess(payload: GrantServerAccessPayload) {
|
||||
if (!canManageUsers.value) return
|
||||
|
||||
const target = payload.target.trim()
|
||||
if (!target) return
|
||||
|
||||
const user = payload.user
|
||||
const existingMember =
|
||||
findMemberByTarget(user.id) ?? findMemberByTarget(user.username) ?? findMemberByTarget(target)
|
||||
if (existingMember) {
|
||||
await updateMemberRole(existingMember, payload.role)
|
||||
if (payload.addAsFriend) {
|
||||
await sendFriendRequest(user.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await client.archon.server_users_v1.add(serverId, {
|
||||
user_id: user.id,
|
||||
role: accessRoleToApiRole(payload.role),
|
||||
})
|
||||
await invalidateServerUsers()
|
||||
await invalidateActionLog()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.inviteSentTitle),
|
||||
text: formatMessage(messages.inviteSentText, {
|
||||
target: user.username,
|
||||
role: formatRole(payload.role),
|
||||
}),
|
||||
})
|
||||
if (payload.addAsFriend) await sendFriendRequest(user.id)
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.inviteFailedTitle),
|
||||
text: formatErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFriendRequest(userIdOrUsername: string) {
|
||||
const friends = await queryClient.ensureQueryData({
|
||||
queryKey: friendsQueryKey,
|
||||
queryFn: () => client.labrinth.friends_v3.list(),
|
||||
})
|
||||
|
||||
if (hasFriendRelationship(friends, userIdOrUsername)) return
|
||||
|
||||
try {
|
||||
await client.labrinth.friends_v3.add(userIdOrUsername)
|
||||
await queryClient.invalidateQueries({ queryKey: friendsQueryKey })
|
||||
} catch (error) {
|
||||
if (isSuppressedFriendRequestError(error)) return
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.friendRequestFailedTitle),
|
||||
text: formatErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function hasFriendRelationship(friends: Labrinth.Friends.v3.UserFriend[], userId: string) {
|
||||
return friends.some((friend) => friend.id === userId || friend.friend_id === userId)
|
||||
}
|
||||
|
||||
function getFriendRelationshipUserIds(friends: Labrinth.Friends.v3.UserFriend[]) {
|
||||
return [...new Set(friends.flatMap((friend) => [friend.id, friend.friend_id]))]
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { IconComponent } from '@modrinth/assets'
|
||||
import {
|
||||
DatabaseBackupIcon,
|
||||
FileIcon,
|
||||
PackageIcon,
|
||||
PowerIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
import type { DropdownFilterBarOption } from '#ui/components/base/DropdownFilterBar.vue'
|
||||
import type {
|
||||
TimeFrameLastUnit,
|
||||
TimeFrameMode,
|
||||
TimeFramePreset,
|
||||
} from '#ui/components/base/TimeFramePicker.vue'
|
||||
import { defineMessage, type MessageDescriptor } from '#ui/composables/i18n'
|
||||
|
||||
export const SUPPORT_ACTION_LOG_USER_FILTER = 'support'
|
||||
export const SERVER_SCOPED_ACTION_LOG_WORLD_FILTER = '__server_scoped__'
|
||||
|
||||
export const actionLogActionNames = [
|
||||
'server_created',
|
||||
'changed_server_name',
|
||||
'changed_server_subdomain',
|
||||
'server_reallocated',
|
||||
'server_plan_changed',
|
||||
'user_invited',
|
||||
'user_invite_revoked',
|
||||
'user_permission_modified',
|
||||
'user_removed',
|
||||
'addon_added',
|
||||
'addon_uploaded',
|
||||
'addon_disabled',
|
||||
'addon_enabled',
|
||||
'addon_deleted',
|
||||
'addon_updated',
|
||||
'modpack_changed',
|
||||
'modpack_unlinked',
|
||||
'server_repaired',
|
||||
'server_reset',
|
||||
'server_started',
|
||||
'server_stopped',
|
||||
'server_restarted',
|
||||
'server_killed',
|
||||
'port_allocation_added',
|
||||
'port_allocation_removed',
|
||||
'loader_version_edited',
|
||||
'game_version_edited',
|
||||
'server_properties_modified',
|
||||
'file_uploaded',
|
||||
'file_deleted',
|
||||
'file_renamed',
|
||||
'file_edited',
|
||||
'sftp_login',
|
||||
'console_command_executed',
|
||||
'console_cleared',
|
||||
'backup_created',
|
||||
'backup_renamed',
|
||||
'backup_restored',
|
||||
'backup_deleted',
|
||||
'startup_command_modified',
|
||||
'java_runtime_modified',
|
||||
'java_version_modified',
|
||||
] as const satisfies readonly Archon.Actions.v1.ActionName[]
|
||||
|
||||
export type ActionLogFilterActionName = (typeof actionLogActionNames)[number]
|
||||
|
||||
const actionLogActionNameSet = new Set<string>(actionLogActionNames)
|
||||
|
||||
export const actionLogActionGroups = [
|
||||
{
|
||||
key: 'server',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.server',
|
||||
defaultMessage: 'Server',
|
||||
}),
|
||||
icon: ServerIcon,
|
||||
actions: [
|
||||
'server_created',
|
||||
'server_reallocated',
|
||||
'server_plan_changed',
|
||||
'server_repaired',
|
||||
'server_reset',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'power-console',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.power-console',
|
||||
defaultMessage: 'Power and console',
|
||||
}),
|
||||
icon: PowerIcon,
|
||||
actions: [
|
||||
'server_started',
|
||||
'server_stopped',
|
||||
'server_restarted',
|
||||
'server_killed',
|
||||
'console_command_executed',
|
||||
'console_cleared',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.users',
|
||||
defaultMessage: 'Users and invites',
|
||||
}),
|
||||
icon: UsersIcon,
|
||||
actions: ['user_invited', 'user_invite_revoked', 'user_permission_modified', 'user_removed'],
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.content',
|
||||
defaultMessage: 'Content and modpack',
|
||||
}),
|
||||
icon: PackageIcon,
|
||||
actions: [
|
||||
'addon_added',
|
||||
'addon_uploaded',
|
||||
'addon_disabled',
|
||||
'addon_enabled',
|
||||
'addon_updated',
|
||||
'addon_deleted',
|
||||
'modpack_changed',
|
||||
'modpack_unlinked',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.files',
|
||||
defaultMessage: 'Files and SFTP',
|
||||
}),
|
||||
icon: FileIcon,
|
||||
actions: ['file_uploaded', 'file_edited', 'file_renamed', 'file_deleted', 'sftp_login'],
|
||||
},
|
||||
{
|
||||
key: 'backups',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.backups',
|
||||
defaultMessage: 'Backups',
|
||||
}),
|
||||
icon: DatabaseBackupIcon,
|
||||
actions: ['backup_created', 'backup_renamed', 'backup_restored', 'backup_deleted'],
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: defineMessage({
|
||||
id: 'servers.access-page.activity-log-filter.action-group.settings',
|
||||
defaultMessage: 'Settings and runtime',
|
||||
}),
|
||||
icon: SettingsIcon,
|
||||
actions: [
|
||||
'changed_server_name',
|
||||
'changed_server_subdomain',
|
||||
'port_allocation_added',
|
||||
'port_allocation_removed',
|
||||
'loader_version_edited',
|
||||
'game_version_edited',
|
||||
'server_properties_modified',
|
||||
'startup_command_modified',
|
||||
'java_runtime_modified',
|
||||
'java_version_modified',
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
key: string
|
||||
label: MessageDescriptor
|
||||
icon: IconComponent
|
||||
actions: readonly ActionLogFilterActionName[]
|
||||
}[]
|
||||
|
||||
export type AuditLogTimeframeSelection = {
|
||||
mode: TimeFrameMode
|
||||
preset: TimeFramePreset
|
||||
lastAmount: number
|
||||
lastUnit: TimeFrameLastUnit
|
||||
customStartDate: string
|
||||
customEndDate: string
|
||||
}
|
||||
|
||||
export function isActionLogActionName(action: string): action is ActionLogFilterActionName {
|
||||
return actionLogActionNameSet.has(action)
|
||||
}
|
||||
|
||||
export function compareFilterOptions(
|
||||
left: DropdownFilterBarOption,
|
||||
right: DropdownFilterBarOption,
|
||||
) {
|
||||
return left.label.localeCompare(right.label)
|
||||
}
|
||||
|
||||
export function getAuditLogTimeframeRange(
|
||||
selection: AuditLogTimeframeSelection,
|
||||
): { start: Date; end: Date } | null {
|
||||
const now = getRoundedNow()
|
||||
|
||||
if (selection.mode === 'last') {
|
||||
return getLastAuditLogTimeframeRange(selection.lastAmount, selection.lastUnit, now)
|
||||
}
|
||||
|
||||
if (selection.mode === 'custom_range') {
|
||||
const startDate = parseDateInputValue(selection.customStartDate)
|
||||
const endDate = parseDateInputValue(selection.customEndDate)
|
||||
if (!startDate || !endDate) return null
|
||||
|
||||
const [minDate, maxDate] =
|
||||
startDate.getTime() > endDate.getTime() ? [endDate, startDate] : [startDate, endDate]
|
||||
|
||||
return {
|
||||
start: startOfDay(minDate),
|
||||
end: endOfDay(maxDate),
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.mode !== 'preset') {
|
||||
return null
|
||||
}
|
||||
|
||||
return getPresetAuditLogTimeframeRange(selection.preset, now)
|
||||
}
|
||||
|
||||
export function getActionLogEntryId(entry: Archon.Actions.v1.ActionEntry) {
|
||||
return JSON.stringify([
|
||||
entry.timestamp,
|
||||
entry.actor.type,
|
||||
entry.actor.type === 'user' ? entry.actor.user_id : (entry.actor.user_id ?? 'support'),
|
||||
entry.server_id,
|
||||
entry.world_id ?? null,
|
||||
entry.action.action,
|
||||
stableStringify(entry.action.metadata),
|
||||
])
|
||||
}
|
||||
|
||||
function parseDateInputValue(value: string) {
|
||||
const [yearValue, monthValue, dayValue] = value.split('-').map(Number)
|
||||
if (!yearValue || !monthValue || !dayValue) return null
|
||||
|
||||
const date = new Date(yearValue, monthValue - 1, dayValue)
|
||||
if (
|
||||
date.getFullYear() !== yearValue ||
|
||||
date.getMonth() !== monthValue - 1 ||
|
||||
date.getDate() !== dayValue
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setDate(nextDate.getDate() + days)
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function subtractCalendarMonths(date: Date, months: number) {
|
||||
const nextDate = new Date(date)
|
||||
const day = nextDate.getDate()
|
||||
nextDate.setDate(1)
|
||||
nextDate.setMonth(nextDate.getMonth() - months)
|
||||
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
|
||||
nextDate.setDate(Math.min(day, daysInMonth))
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function getRoundedNow() {
|
||||
const now = Date.now()
|
||||
return new Date(Math.floor(now / 60000) * 60000)
|
||||
}
|
||||
|
||||
function getPresetAuditLogTimeframeRange(
|
||||
preset: TimeFramePreset,
|
||||
now: Date,
|
||||
): { start: Date; end: Date } | null {
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
return { start: startOfDay(now), end: endOfDay(now) }
|
||||
case 'yesterday': {
|
||||
const yesterday = addDays(now, -1)
|
||||
return { start: startOfDay(yesterday), end: endOfDay(yesterday) }
|
||||
}
|
||||
case 'last_7_days':
|
||||
return { start: startOfDay(addDays(now, -6)), end: endOfDay(now) }
|
||||
case 'last_14_days':
|
||||
return { start: startOfDay(addDays(now, -13)), end: endOfDay(now) }
|
||||
case 'last_30_days':
|
||||
return { start: startOfDay(addDays(now, -29)), end: endOfDay(now) }
|
||||
case 'last_90_days':
|
||||
return { start: startOfDay(addDays(now, -89)), end: endOfDay(now) }
|
||||
case 'last_180_days':
|
||||
return { start: startOfDay(addDays(now, -179)), end: endOfDay(now) }
|
||||
case 'year_to_date':
|
||||
return { start: new Date(now.getFullYear(), 0, 1), end: endOfDay(now) }
|
||||
case 'all_time':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getLastAuditLogTimeframeRange(
|
||||
amountValue: number,
|
||||
unit: TimeFrameLastUnit,
|
||||
now: Date,
|
||||
): { start: Date; end: Date } {
|
||||
const amount = Math.max(1, Math.floor(amountValue))
|
||||
|
||||
switch (unit) {
|
||||
case 'hours':
|
||||
return { start: new Date(now.getTime() - amount * 60 * 60 * 1000), end: now }
|
||||
case 'days':
|
||||
return { start: new Date(now.getTime() - amount * 24 * 60 * 60 * 1000), end: now }
|
||||
case 'weeks':
|
||||
return { start: new Date(now.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end: now }
|
||||
case 'months':
|
||||
return { start: subtractCalendarMonths(now, amount), end: now }
|
||||
}
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
}
|
||||
|
||||
function endOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return 'undefined'
|
||||
}
|
||||
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(',')}]`
|
||||
}
|
||||
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
DropdownFilterBarCategory,
|
||||
DropdownFilterBarItem,
|
||||
DropdownFilterBarOption,
|
||||
} from '#ui/components/base/DropdownFilterBar.vue'
|
||||
import type {
|
||||
TimeFrameLastUnit,
|
||||
TimeFrameMode,
|
||||
TimeFramePreset,
|
||||
} from '#ui/components/base/TimeFramePicker.vue'
|
||||
import type { ServerAuditLogEntry } from '#ui/components/servers/access'
|
||||
import { parseAuditEvent } from '#ui/components/servers/access/events'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import type { AbstractWebNotificationManager } from '#ui/providers/web-notifications'
|
||||
|
||||
import {
|
||||
actionLogActionGroups,
|
||||
type ActionLogFilterActionName,
|
||||
compareFilterOptions,
|
||||
getActionLogEntryId,
|
||||
getAuditLogTimeframeRange,
|
||||
isActionLogActionName,
|
||||
SERVER_SCOPED_ACTION_LOG_WORLD_FILTER,
|
||||
SUPPORT_ACTION_LOG_USER_FILTER,
|
||||
} from './audit-log-utils'
|
||||
import { accessMessages, actionLogActionMessages } from './messages'
|
||||
|
||||
type AuditLogFilterKey = 'users' | 'worlds' | 'actions'
|
||||
|
||||
type UseAccessAuditLogOptions = {
|
||||
client: AbstractModrinthClient
|
||||
serverId: string
|
||||
serverFull: ComputedRef<Archon.Servers.v1.ServerFull | null>
|
||||
showAuditLogInstances: ComputedRef<boolean>
|
||||
addNotification: AbstractWebNotificationManager['addNotification']
|
||||
}
|
||||
|
||||
const ACTION_LOG_PAGE_SIZE = 200
|
||||
const ACTION_LOG_FILTER_OVERLAY_MS = 750
|
||||
|
||||
export function useAccessAuditLog({
|
||||
client,
|
||||
serverId,
|
||||
serverFull,
|
||||
showAuditLogInstances,
|
||||
addNotification,
|
||||
}: UseAccessAuditLogOptions) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const auditLogFilters = ref<Record<string, string[]>>({
|
||||
users: [],
|
||||
worlds: [],
|
||||
actions: [],
|
||||
})
|
||||
const auditLogTimeframeMode = ref<TimeFrameMode>('preset')
|
||||
const auditLogTimeframePreset = ref<TimeFramePreset>('last_7_days')
|
||||
const auditLogTimeframeLastAmount = ref(30)
|
||||
const auditLogTimeframeLastUnit = ref<TimeFrameLastUnit>('days')
|
||||
const auditLogTimeframeCustomStartDate = ref('')
|
||||
const auditLogTimeframeCustomEndDate = ref('')
|
||||
const auditLogSortDirection = ref<Archon.Actions.v1.SortOrder>('desc')
|
||||
|
||||
const worldOptions = computed(
|
||||
() => serverFull.value?.worlds.map((world) => ({ id: world.id, name: world.name })) ?? [],
|
||||
)
|
||||
const isAuditLogWorldFilterVisible = computed(
|
||||
() => showAuditLogInstances.value && worldOptions.value.length > 0,
|
||||
)
|
||||
|
||||
const worldById = computed(
|
||||
() => new Map(worldOptions.value.map((world) => [world.id, world] as const)),
|
||||
)
|
||||
|
||||
const backupById = computed(() => {
|
||||
const backups = new Map<string, Archon.Backups.v1.Backup>()
|
||||
for (const world of serverFull.value?.worlds ?? []) {
|
||||
for (const backup of world.backups ?? []) {
|
||||
backups.set(backup.id, backup)
|
||||
}
|
||||
}
|
||||
return backups
|
||||
})
|
||||
|
||||
const actionLogDateFilter = computed(() => {
|
||||
const range = getAuditLogTimeframeRange({
|
||||
mode: auditLogTimeframeMode.value,
|
||||
preset: auditLogTimeframePreset.value,
|
||||
lastAmount: auditLogTimeframeLastAmount.value,
|
||||
lastUnit: auditLogTimeframeLastUnit.value,
|
||||
customStartDate: auditLogTimeframeCustomStartDate.value,
|
||||
customEndDate: auditLogTimeframeCustomEndDate.value,
|
||||
})
|
||||
|
||||
return {
|
||||
min_datetime: range?.start.toISOString(),
|
||||
max_datetime: range?.end.toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
const actionLogEndpointFilter = computed<Archon.Actions.v1.ActionLogFilter | undefined>(() => {
|
||||
const users = selectedAuditLogFilterValues('users')
|
||||
const worlds = isAuditLogWorldFilterVisible.value ? selectedAuditLogWorldFilterValues() : []
|
||||
const actions = selectedAuditLogFilterValues('actions').filter(isActionLogActionName)
|
||||
const filter: Archon.Actions.v1.ActionLogFilter = {}
|
||||
|
||||
if (users.length > 0) filter.users = users
|
||||
if (worlds.length > 0) filter.worlds = worlds
|
||||
if (actions.length > 0) filter.actions = actions
|
||||
|
||||
return Object.keys(filter).length > 0 ? filter : undefined
|
||||
})
|
||||
const actionLogBaseQueryKey = ['servers', 'action-log', 'v1', 'infinite', serverId] as const
|
||||
const actionLogQueryKey = computed(() => {
|
||||
const filter = actionLogEndpointFilter.value
|
||||
const dateFilter = actionLogDateFilter.value
|
||||
|
||||
return [
|
||||
...actionLogBaseQueryKey,
|
||||
filter ?? null,
|
||||
dateFilter.min_datetime ?? null,
|
||||
dateFilter.max_datetime ?? null,
|
||||
auditLogSortDirection.value,
|
||||
]
|
||||
})
|
||||
const actionLogQuery = useInfiniteQuery({
|
||||
queryKey: actionLogQueryKey,
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
const offset = typeof pageParam === 'number' ? pageParam : 0
|
||||
return client.archon.actions_v1.list(serverId, {
|
||||
limit: ACTION_LOG_PAGE_SIZE,
|
||||
offset,
|
||||
order: auditLogSortDirection.value,
|
||||
filter: actionLogEndpointFilter.value,
|
||||
...actionLogDateFilter.value,
|
||||
})
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
|
||||
initialPageParam: 0,
|
||||
placeholderData: (previousData) => previousData,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const actionLogFilterSignature = computed(() =>
|
||||
JSON.stringify([
|
||||
actionLogEndpointFilter.value ?? null,
|
||||
actionLogDateFilter.value.min_datetime ?? null,
|
||||
actionLogDateFilter.value.max_datetime ?? null,
|
||||
]),
|
||||
)
|
||||
const isActionLogFilterTransitioning = ref(false)
|
||||
const isActionLogSortTransitioning = ref(false)
|
||||
let actionLogFilterTransitionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let actionLogSortTransitionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(actionLogFilterSignature, (_signature, previousSignature) => {
|
||||
if (previousSignature === undefined) return
|
||||
startActionLogFilterTransition()
|
||||
})
|
||||
|
||||
watch(
|
||||
auditLogSortDirection,
|
||||
(_direction, previousDirection) => {
|
||||
if (previousDirection === undefined) return
|
||||
startActionLogSortTransition()
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => actionLogQuery.isFetching.value,
|
||||
(isFetching) => {
|
||||
if (!isFetching && isActionLogSortTransitioning.value) {
|
||||
finishActionLogSortTransition()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (actionLogFilterTransitionTimeout) {
|
||||
clearTimeout(actionLogFilterTransitionTimeout)
|
||||
}
|
||||
if (actionLogSortTransitionTimeout) {
|
||||
clearTimeout(actionLogSortTransitionTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
const auditEntries = computed<ServerAuditLogEntry[]>(() => {
|
||||
const pages = actionLogQuery.data.value?.pages ?? []
|
||||
const entryIdCounts = new Map<string, number>()
|
||||
|
||||
return pages.flatMap((actionLog) =>
|
||||
actionLog.data.map((entry) => {
|
||||
const entryId = getActionLogEntryId(entry)
|
||||
const entryIdCount = entryIdCounts.get(entryId) ?? 0
|
||||
entryIdCounts.set(entryId, entryIdCount + 1)
|
||||
|
||||
return apiActionLogEntryToAuditEntry(
|
||||
entry,
|
||||
actionLog,
|
||||
entryIdCount === 0 ? entryId : `${entryId}-${entryIdCount}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
const hasShownActionLogLoadError = ref(false)
|
||||
const hasMoreActionLogEntries = computed(
|
||||
() => !actionLogQuery.isPlaceholderData.value && actionLogQuery.hasNextPage.value,
|
||||
)
|
||||
const isLoadingMoreActionLogEntries = computed(() => actionLogQuery.isFetchingNextPage.value)
|
||||
const isActionLogFiltering = computed(() => isActionLogFilterTransitioning.value)
|
||||
const initialAuditLogUserFilterOptions = ref<DropdownFilterBarOption[]>([])
|
||||
|
||||
watch(
|
||||
() => actionLogQuery.data.value?.pages ?? [],
|
||||
(pages) => {
|
||||
if (actionLogEndpointFilter.value) return
|
||||
|
||||
initialAuditLogUserFilterOptions.value = mergeAuditLogUserFilterOptions(
|
||||
initialAuditLogUserFilterOptions.value,
|
||||
extractAuditLogUserFilterOptions(pages),
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const auditLogUserFilterOptions = computed<DropdownFilterBarOption[]>(() => {
|
||||
if (initialAuditLogUserFilterOptions.value.length > 0) {
|
||||
return initialAuditLogUserFilterOptions.value
|
||||
}
|
||||
|
||||
return extractAuditLogUserFilterOptions(actionLogQuery.data.value?.pages ?? [])
|
||||
})
|
||||
|
||||
const auditLogWorldFilterOptions = computed<DropdownFilterBarOption[]>(() => [
|
||||
{
|
||||
value: SERVER_SCOPED_ACTION_LOG_WORLD_FILTER,
|
||||
label: formatMessage(accessMessages.serverScopedInstance),
|
||||
searchTerms: [
|
||||
SERVER_SCOPED_ACTION_LOG_WORLD_FILTER,
|
||||
formatMessage(accessMessages.serverScopedInstance),
|
||||
],
|
||||
},
|
||||
...worldOptions.value.map((world) => ({
|
||||
value: world.id,
|
||||
label: world.name,
|
||||
searchTerms: [world.id, world.name],
|
||||
})),
|
||||
])
|
||||
|
||||
const auditLogActionFilterOptions = computed<DropdownFilterBarItem[]>(() =>
|
||||
actionLogActionGroups.flatMap((group) => [
|
||||
{
|
||||
type: 'section-header' as const,
|
||||
key: group.key,
|
||||
label: formatMessage(group.label),
|
||||
icon: group.icon,
|
||||
},
|
||||
...group.actions.map((action) => ({
|
||||
value: action,
|
||||
label: formatActionLogAction(action),
|
||||
searchTerms: [action, action.replaceAll('_', ' ')],
|
||||
})),
|
||||
]),
|
||||
)
|
||||
|
||||
const auditLogFilterCategories = computed<DropdownFilterBarCategory[]>(() => {
|
||||
const categories: DropdownFilterBarCategory[] = [
|
||||
{
|
||||
key: 'users',
|
||||
label: formatMessage(accessMessages.userFilter),
|
||||
options: auditLogUserFilterOptions.value,
|
||||
},
|
||||
]
|
||||
|
||||
if (isAuditLogWorldFilterVisible.value) {
|
||||
categories.push({
|
||||
key: 'worlds',
|
||||
label: formatMessage(accessMessages.instanceFilter),
|
||||
options: auditLogWorldFilterOptions.value,
|
||||
})
|
||||
}
|
||||
|
||||
categories.push({
|
||||
key: 'actions',
|
||||
label: formatMessage(accessMessages.actionTypeFilter),
|
||||
options: auditLogActionFilterOptions.value,
|
||||
searchable: true,
|
||||
searchPlaceholder: formatMessage(accessMessages.actionTypeFilterSearch),
|
||||
submenuClass: 'w-[22rem]',
|
||||
previewDropdownMinWidth: '20rem',
|
||||
})
|
||||
|
||||
return categories
|
||||
})
|
||||
|
||||
const hasActiveAuditLogDateFilter = computed(
|
||||
() => !!actionLogDateFilter.value.min_datetime || !!actionLogDateFilter.value.max_datetime,
|
||||
)
|
||||
|
||||
const hasActiveAuditLogFilters = computed(
|
||||
() =>
|
||||
hasActiveAuditLogDateFilter.value ||
|
||||
(isAuditLogWorldFilterVisible.value
|
||||
? (['users', 'worlds', 'actions'] satisfies AuditLogFilterKey[])
|
||||
: (['users', 'actions'] satisfies AuditLogFilterKey[])
|
||||
).some((key) => selectedAuditLogFilterValues(key).length > 0),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => actionLogQuery.error.value,
|
||||
(actionLogError) => {
|
||||
if (hasShownActionLogLoadError.value || !actionLogError) return
|
||||
|
||||
hasShownActionLogLoadError.value = true
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(accessMessages.loadFailedTitle),
|
||||
text: formatErrorMessage(actionLogError) ?? formatMessage(accessMessages.loadFailedText),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function selectedAuditLogFilterValues(key: AuditLogFilterKey): string[] {
|
||||
const values = auditLogFilters.value[key]
|
||||
return values ? [...values] : []
|
||||
}
|
||||
|
||||
function selectedAuditLogWorldFilterValues(): Array<string | null> {
|
||||
return selectedAuditLogFilterValues('worlds').map((world) =>
|
||||
world === SERVER_SCOPED_ACTION_LOG_WORLD_FILTER ? null : world,
|
||||
)
|
||||
}
|
||||
|
||||
function extractAuditLogUserFilterOptions(
|
||||
pages: Archon.Actions.v1.ActionLogResponse[],
|
||||
): DropdownFilterBarOption[] {
|
||||
const options = new Map<string, DropdownFilterBarOption>()
|
||||
|
||||
for (const page of pages) {
|
||||
for (const entry of page.data) {
|
||||
if (entry.actor.type === 'support') {
|
||||
const userId = entry.actor.user_id ?? null
|
||||
const user = userId ? page.users[userId] : undefined
|
||||
if (!options.has(SUPPORT_ACTION_LOG_USER_FILTER)) {
|
||||
options.set(SUPPORT_ACTION_LOG_USER_FILTER, {
|
||||
value: SUPPORT_ACTION_LOG_USER_FILTER,
|
||||
label: formatMessage(accessMessages.supportActor),
|
||||
searchTerms: [
|
||||
SUPPORT_ACTION_LOG_USER_FILTER,
|
||||
formatMessage(accessMessages.supportActor),
|
||||
userId,
|
||||
user?.username,
|
||||
].filter(Boolean) as string[],
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const id = entry.actor.user_id
|
||||
const user = page.users[id]
|
||||
if (!options.has(id)) {
|
||||
options.set(id, {
|
||||
value: id,
|
||||
label: user?.username ?? id,
|
||||
searchTerms: [id, user?.username].filter(Boolean) as string[],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...options.values()].sort(compareFilterOptions)
|
||||
}
|
||||
|
||||
function mergeAuditLogUserFilterOptions(
|
||||
existingOptions: DropdownFilterBarOption[],
|
||||
nextOptions: DropdownFilterBarOption[],
|
||||
): DropdownFilterBarOption[] {
|
||||
const options = new Map(existingOptions.map((option) => [option.value, option] as const))
|
||||
|
||||
for (const option of nextOptions) {
|
||||
options.set(option.value, option)
|
||||
}
|
||||
|
||||
return [...options.values()].sort(compareFilterOptions)
|
||||
}
|
||||
|
||||
function formatActionLogAction(action: ActionLogFilterActionName): string {
|
||||
return formatMessage(actionLogActionMessages[action])
|
||||
}
|
||||
|
||||
function loadMoreActionLogEntries() {
|
||||
if (
|
||||
isActionLogFilterTransitioning.value ||
|
||||
actionLogQuery.isPlaceholderData.value ||
|
||||
!actionLogQuery.hasNextPage.value ||
|
||||
actionLogQuery.isFetchingNextPage.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
void actionLogQuery.fetchNextPage()
|
||||
}
|
||||
|
||||
function startActionLogFilterTransition() {
|
||||
isActionLogFilterTransitioning.value = true
|
||||
|
||||
if (actionLogFilterTransitionTimeout) {
|
||||
clearTimeout(actionLogFilterTransitionTimeout)
|
||||
}
|
||||
|
||||
actionLogFilterTransitionTimeout = setTimeout(() => {
|
||||
isActionLogFilterTransitioning.value = false
|
||||
actionLogFilterTransitionTimeout = null
|
||||
}, ACTION_LOG_FILTER_OVERLAY_MS)
|
||||
}
|
||||
|
||||
function startActionLogSortTransition() {
|
||||
isActionLogSortTransitioning.value = true
|
||||
|
||||
if (actionLogSortTransitionTimeout) {
|
||||
clearTimeout(actionLogSortTransitionTimeout)
|
||||
}
|
||||
|
||||
actionLogSortTransitionTimeout = setTimeout(() => {
|
||||
isActionLogSortTransitioning.value = false
|
||||
actionLogSortTransitionTimeout = null
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
function finishActionLogSortTransition() {
|
||||
if (actionLogSortTransitionTimeout) {
|
||||
clearTimeout(actionLogSortTransitionTimeout)
|
||||
}
|
||||
|
||||
actionLogSortTransitionTimeout = setTimeout(() => {
|
||||
isActionLogSortTransitioning.value = false
|
||||
actionLogSortTransitionTimeout = null
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function apiActionLogEntryToAuditEntry(
|
||||
entry: Archon.Actions.v1.ActionEntry,
|
||||
actionLog: Archon.Actions.v1.ActionLogResponse,
|
||||
id: string,
|
||||
): ServerAuditLogEntry {
|
||||
const event = parseAuditEvent(entry, {
|
||||
serverId,
|
||||
users: actionLog.users,
|
||||
addons: actionLog.addons,
|
||||
worldById: worldById.value,
|
||||
backupById: backupById.value,
|
||||
versions: actionLog.versions ?? {},
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
actor: event.props.actor,
|
||||
world: event.props.world,
|
||||
event,
|
||||
timestamp: entry.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
async function invalidateActionLog() {
|
||||
await queryClient.invalidateQueries({ queryKey: actionLogBaseQueryKey })
|
||||
}
|
||||
|
||||
return {
|
||||
auditEntries,
|
||||
auditLogFilterCategories,
|
||||
auditLogFilters,
|
||||
auditLogSortDirection,
|
||||
auditLogTimeframeCustomEndDate,
|
||||
auditLogTimeframeCustomStartDate,
|
||||
auditLogTimeframeLastAmount,
|
||||
auditLogTimeframeLastUnit,
|
||||
auditLogTimeframeMode,
|
||||
auditLogTimeframePreset,
|
||||
hasActiveAuditLogFilters,
|
||||
hasMoreActionLogEntries,
|
||||
invalidateActionLog,
|
||||
isActionLogFiltering,
|
||||
isActionLogSortTransitioning,
|
||||
isLoadingMoreActionLogEntries,
|
||||
loadMoreActionLogEntries,
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string | undefined {
|
||||
return error instanceof Error ? error.message : undefined
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { defineMessages } from '#ui/composables/i18n'
|
||||
|
||||
export const accessMessages = defineMessages({
|
||||
searchUsersPlaceholder: {
|
||||
id: 'servers.access-page.search-users-placeholder',
|
||||
defaultMessage: 'Search {count} {count, plural, one {user} other {users}}...',
|
||||
},
|
||||
inviteFriends: {
|
||||
id: 'servers.access-page.invite-friends',
|
||||
defaultMessage: 'Add user',
|
||||
},
|
||||
activityLogTitle: {
|
||||
id: 'servers.access-page.activity-log-title',
|
||||
defaultMessage: 'Activity log',
|
||||
},
|
||||
addFilter: {
|
||||
id: 'servers.access-page.activity-log-filter.add',
|
||||
defaultMessage: 'Add filter',
|
||||
},
|
||||
clearFilters: {
|
||||
id: 'servers.access-page.activity-log-filter.clear',
|
||||
defaultMessage: 'Clear filters',
|
||||
},
|
||||
emptyFilterOptions: {
|
||||
id: 'servers.access-page.activity-log-filter.empty-options',
|
||||
defaultMessage: 'No options available.',
|
||||
},
|
||||
emptyFilterSearch: {
|
||||
id: 'servers.access-page.activity-log-filter.empty-search',
|
||||
defaultMessage: 'No options found.',
|
||||
},
|
||||
userFilter: {
|
||||
id: 'servers.access-page.activity-log-filter.users',
|
||||
defaultMessage: 'User',
|
||||
},
|
||||
supportActor: {
|
||||
id: 'servers.access-page.activity-log-filter.support-actor',
|
||||
defaultMessage: 'Support',
|
||||
},
|
||||
instanceFilter: {
|
||||
id: 'servers.access-page.activity-log-filter.instances',
|
||||
defaultMessage: 'Instances',
|
||||
},
|
||||
serverScopedInstance: {
|
||||
id: 'servers.access-page.activity-log-filter.server-scoped-instance',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
actionTypeFilter: {
|
||||
id: 'servers.access-page.activity-log-filter.action-types',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
actionTypeFilterSearch: {
|
||||
id: 'servers.access-page.activity-log-filter.action-types-search',
|
||||
defaultMessage: 'Search actions...',
|
||||
},
|
||||
allRoles: {
|
||||
id: 'servers.access-page.role-filter.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
selectedRoleFilter: {
|
||||
id: 'servers.access-page.role-filter.selected',
|
||||
defaultMessage: 'Role: {role}',
|
||||
},
|
||||
ownerRole: {
|
||||
id: 'servers.access-page.role.owner',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
ownerDescription: {
|
||||
id: 'servers.access-page.role.owner-description',
|
||||
defaultMessage: 'Full access including billing, members, and destructive actions.',
|
||||
},
|
||||
editorRole: {
|
||||
id: 'servers.access-page.role.editor',
|
||||
defaultMessage: 'Editor',
|
||||
},
|
||||
editorDescription: {
|
||||
id: 'servers.access-page.role.editor-description',
|
||||
defaultMessage: 'Manage instance content, files, backups, and other settings.',
|
||||
},
|
||||
viewerRole: {
|
||||
id: 'servers.access-page.role.viewer',
|
||||
defaultMessage: 'Limited',
|
||||
},
|
||||
viewerDescription: {
|
||||
id: 'servers.access-page.role.viewer-description',
|
||||
defaultMessage: 'Start, stop, and view the server without making changes.',
|
||||
},
|
||||
inviteSentTitle: {
|
||||
id: 'servers.access-page.notification.invite-sent.title',
|
||||
defaultMessage: 'Invite sent',
|
||||
},
|
||||
inviteSentText: {
|
||||
id: 'servers.access-page.notification.invite-sent.text',
|
||||
defaultMessage: 'Invited {target} as {role}.',
|
||||
},
|
||||
inviteResentTitle: {
|
||||
id: 'servers.access-page.notification.invite-resent.title',
|
||||
defaultMessage: 'Invite resent',
|
||||
},
|
||||
inviteResentText: {
|
||||
id: 'servers.access-page.notification.invite-resent.text',
|
||||
defaultMessage: 'Sent another invite to {target}.',
|
||||
},
|
||||
inviteCancelledTitle: {
|
||||
id: 'servers.access-page.notification.invite-cancelled.title',
|
||||
defaultMessage: 'Invite cancelled',
|
||||
},
|
||||
inviteCancelledText: {
|
||||
id: 'servers.access-page.notification.invite-cancelled.text',
|
||||
defaultMessage: 'Cancelled the invite for {target}.',
|
||||
},
|
||||
memberRemovedTitle: {
|
||||
id: 'servers.access-page.notification.member-removed.title',
|
||||
defaultMessage: 'Access removed',
|
||||
},
|
||||
memberRemovedText: {
|
||||
id: 'servers.access-page.notification.member-removed.text',
|
||||
defaultMessage: 'Removed {target} from this server.',
|
||||
},
|
||||
loadFailedTitle: {
|
||||
id: 'servers.access-page.notification.load-failed.title',
|
||||
defaultMessage: 'Access could not be loaded',
|
||||
},
|
||||
loadFailedText: {
|
||||
id: 'servers.access-page.notification.load-failed.text',
|
||||
defaultMessage: 'Refresh the page to try again.',
|
||||
},
|
||||
inviteFailedTitle: {
|
||||
id: 'servers.access-page.notification.invite-failed.title',
|
||||
defaultMessage: 'Invite could not be sent',
|
||||
},
|
||||
friendRequestFailedTitle: {
|
||||
id: 'servers.access-page.notification.friend-request-failed.title',
|
||||
defaultMessage: 'Friend request could not be sent',
|
||||
},
|
||||
removeFailedTitle: {
|
||||
id: 'servers.access-page.notification.remove-failed.title',
|
||||
defaultMessage: 'Access could not be removed',
|
||||
},
|
||||
roleUpdateFailedTitle: {
|
||||
id: 'servers.access-page.notification.role-update-failed.title',
|
||||
defaultMessage: 'Role could not be updated',
|
||||
},
|
||||
})
|
||||
|
||||
export const actionLogActionMessages = defineMessages({
|
||||
server_created: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-created',
|
||||
defaultMessage: 'Created server',
|
||||
},
|
||||
changed_server_name: {
|
||||
id: 'servers.access-page.activity-log-filter.action.changed-server-name',
|
||||
defaultMessage: 'Changed server name',
|
||||
},
|
||||
changed_server_subdomain: {
|
||||
id: 'servers.access-page.activity-log-filter.action.changed-server-subdomain',
|
||||
defaultMessage: 'Changed server subdomain',
|
||||
},
|
||||
server_reallocated: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-reallocated',
|
||||
defaultMessage: 'Reallocated server',
|
||||
},
|
||||
server_plan_changed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-plan-changed',
|
||||
defaultMessage: 'Changed plan',
|
||||
},
|
||||
user_invited: {
|
||||
id: 'servers.access-page.activity-log-filter.action.user-invited',
|
||||
defaultMessage: 'Invited user',
|
||||
},
|
||||
user_invite_revoked: {
|
||||
id: 'servers.access-page.activity-log-filter.action.user-invite-revoked',
|
||||
defaultMessage: 'Revoked user invite',
|
||||
},
|
||||
user_permission_modified: {
|
||||
id: 'servers.access-page.activity-log-filter.action.user-permission-modified',
|
||||
defaultMessage: 'Changed user permissions',
|
||||
},
|
||||
user_removed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.user-removed',
|
||||
defaultMessage: 'Removed user',
|
||||
},
|
||||
addon_added: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-added',
|
||||
defaultMessage: 'Added content',
|
||||
},
|
||||
addon_uploaded: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-uploaded',
|
||||
defaultMessage: 'Uploaded content',
|
||||
},
|
||||
addon_disabled: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-disabled',
|
||||
defaultMessage: 'Disabled content',
|
||||
},
|
||||
addon_enabled: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-enabled',
|
||||
defaultMessage: 'Enabled content',
|
||||
},
|
||||
addon_deleted: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-deleted',
|
||||
defaultMessage: 'Deleted content',
|
||||
},
|
||||
addon_updated: {
|
||||
id: 'servers.access-page.activity-log-filter.action.addon-updated',
|
||||
defaultMessage: 'Updated content',
|
||||
},
|
||||
modpack_changed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.modpack-changed',
|
||||
defaultMessage: 'Changed modpack',
|
||||
},
|
||||
modpack_unlinked: {
|
||||
id: 'servers.access-page.activity-log-filter.action.modpack-unlinked',
|
||||
defaultMessage: 'Unlinked modpack',
|
||||
},
|
||||
server_repaired: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-repaired',
|
||||
defaultMessage: 'Repaired server',
|
||||
},
|
||||
server_reset: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-reset',
|
||||
defaultMessage: 'Reset server',
|
||||
},
|
||||
server_started: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-started',
|
||||
defaultMessage: 'Started server',
|
||||
},
|
||||
server_stopped: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-stopped',
|
||||
defaultMessage: 'Stopped server',
|
||||
},
|
||||
server_restarted: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-restarted',
|
||||
defaultMessage: 'Restarted server',
|
||||
},
|
||||
server_killed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-killed',
|
||||
defaultMessage: 'Killed server',
|
||||
},
|
||||
port_allocation_added: {
|
||||
id: 'servers.access-page.activity-log-filter.action.port-allocation-added',
|
||||
defaultMessage: 'Added port allocation',
|
||||
},
|
||||
port_allocation_removed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.port-allocation-removed',
|
||||
defaultMessage: 'Removed port allocation',
|
||||
},
|
||||
loader_version_edited: {
|
||||
id: 'servers.access-page.activity-log-filter.action.loader-version-edited',
|
||||
defaultMessage: 'Changed loader version',
|
||||
},
|
||||
game_version_edited: {
|
||||
id: 'servers.access-page.activity-log-filter.action.game-version-edited',
|
||||
defaultMessage: 'Changed Minecraft version',
|
||||
},
|
||||
server_properties_modified: {
|
||||
id: 'servers.access-page.activity-log-filter.action.server-properties-modified',
|
||||
defaultMessage: 'Modified server properties',
|
||||
},
|
||||
file_uploaded: {
|
||||
id: 'servers.access-page.activity-log-filter.action.file-uploaded',
|
||||
defaultMessage: 'Uploaded file',
|
||||
},
|
||||
file_deleted: {
|
||||
id: 'servers.access-page.activity-log-filter.action.file-deleted',
|
||||
defaultMessage: 'Deleted file',
|
||||
},
|
||||
file_renamed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.file-renamed',
|
||||
defaultMessage: 'Renamed file',
|
||||
},
|
||||
file_edited: {
|
||||
id: 'servers.access-page.activity-log-filter.action.file-edited',
|
||||
defaultMessage: 'Edited file',
|
||||
},
|
||||
sftp_login: {
|
||||
id: 'servers.access-page.activity-log-filter.action.sftp-login',
|
||||
defaultMessage: 'Logged in via SFTP',
|
||||
},
|
||||
console_command_executed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.console-command-executed',
|
||||
defaultMessage: 'Ran console command',
|
||||
},
|
||||
console_cleared: {
|
||||
id: 'servers.access-page.activity-log-filter.action.console-cleared',
|
||||
defaultMessage: 'Cleared console',
|
||||
},
|
||||
backup_created: {
|
||||
id: 'servers.access-page.activity-log-filter.action.backup-created',
|
||||
defaultMessage: 'Created backup',
|
||||
},
|
||||
backup_renamed: {
|
||||
id: 'servers.access-page.activity-log-filter.action.backup-renamed',
|
||||
defaultMessage: 'Renamed backup',
|
||||
},
|
||||
backup_restored: {
|
||||
id: 'servers.access-page.activity-log-filter.action.backup-restored',
|
||||
defaultMessage: 'Restored backup',
|
||||
},
|
||||
backup_deleted: {
|
||||
id: 'servers.access-page.activity-log-filter.action.backup-deleted',
|
||||
defaultMessage: 'Deleted backup',
|
||||
},
|
||||
startup_command_modified: {
|
||||
id: 'servers.access-page.activity-log-filter.action.startup-command-modified',
|
||||
defaultMessage: 'Changed startup command',
|
||||
},
|
||||
java_runtime_modified: {
|
||||
id: 'servers.access-page.activity-log-filter.action.java-runtime-modified',
|
||||
defaultMessage: 'Changed Java runtime',
|
||||
},
|
||||
java_version_modified: {
|
||||
id: 'servers.access-page.activity-log-filter.action.java-version-modified',
|
||||
defaultMessage: 'Changed Java version',
|
||||
},
|
||||
})
|
||||
@@ -49,7 +49,12 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" size="large">
|
||||
<button class="ml-auto" @click="openModal">
|
||||
<button
|
||||
v-tooltip="!canSetup ? permissionDeniedMessage : undefined"
|
||||
class="ml-auto"
|
||||
:disabled="!canSetup"
|
||||
@click="openModal"
|
||||
>
|
||||
{{ formatMessage(messages.setupServerButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -62,6 +67,8 @@
|
||||
:show-snapshot-toggle="true"
|
||||
:search-modpacks="searchModpacks"
|
||||
:get-project-versions="getProjectVersions"
|
||||
:finish-disabled="!canSetup"
|
||||
:finish-disabled-tooltip="!canSetup ? permissionDeniedMessage : undefined"
|
||||
@hide="() => {}"
|
||||
@browse-modpacks="onBrowseModpacks"
|
||||
@create="onCreate"
|
||||
@@ -77,6 +84,7 @@ import {
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
useServerPermissions,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -90,6 +98,7 @@ import { injectModrinthServerContext } from '#ui/providers'
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { canSetup, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
const messages = defineMessages({
|
||||
welcomeTitle: {
|
||||
@@ -196,11 +205,16 @@ const uploadPercent = computed(() =>
|
||||
totalBytes.value > 0 ? Math.round((uploadedBytes.value / totalBytes.value) * 100) : 0,
|
||||
)
|
||||
|
||||
const openModal = () => modalRef.value?.show()
|
||||
const openModal = () => {
|
||||
if (!canSetup.value) return
|
||||
modalRef.value?.show()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => modalRef.value?.hide())
|
||||
|
||||
function onBrowseModpacks() {
|
||||
if (!canSetup.value) return
|
||||
|
||||
if (props.browseModpacks) {
|
||||
props.browseModpacks({
|
||||
serverId,
|
||||
@@ -217,6 +231,11 @@ function onBrowseModpacks() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!canSetup.value && route.query.resumeModal) {
|
||||
router.replace({ query: {} })
|
||||
return
|
||||
}
|
||||
|
||||
if (route.query.resumeModal === 'setup-type') {
|
||||
router.replace({ query: {} })
|
||||
openModal()
|
||||
@@ -263,6 +282,11 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
||||
}
|
||||
|
||||
const onCreate = async (config: CreationFlowContextValue) => {
|
||||
if (!canSetup.value) {
|
||||
config.loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Handle mrpack file upload
|
||||
if (config.setupType.value === 'modpack' && config.modpackFile.value) {
|
||||
modalRef.value?.hide()
|
||||
|
||||
@@ -28,11 +28,27 @@
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<ReadyTransition :pending="backupsReadyPending">
|
||||
<BackupCreateModal ref="createBackupModal" :backups="completedBackups" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="completedBackups" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" />
|
||||
<BackupCreateModal
|
||||
ref="createBackupModal"
|
||||
:backups="completedBackups"
|
||||
:can-create="canManageBackups"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
/>
|
||||
<BackupRenameModal
|
||||
ref="renameBackupModal"
|
||||
:backups="completedBackups"
|
||||
:can-rename="canManageBackups"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
/>
|
||||
<BackupRestoreModal
|
||||
ref="restoreBackupModal"
|
||||
:can-restore="canManageBackups"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
/>
|
||||
<BackupDeleteModal
|
||||
ref="deleteBackupModal"
|
||||
:can-delete="canManageBackups"
|
||||
:permission-denied-message="permissionDeniedMessage"
|
||||
@delete="deleteBackup"
|
||||
@bulk-delete="bulkDelete"
|
||||
/>
|
||||
@@ -122,6 +138,7 @@
|
||||
v-for="(backup, backupIndex) in group.backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
class="flex gap-2"
|
||||
:data-backup-id="backup.id"
|
||||
>
|
||||
<div class="flex w-5 flex-col items-center">
|
||||
<div
|
||||
@@ -140,17 +157,20 @@
|
||||
class="my-1.5 min-w-0 flex-1"
|
||||
:backup="backup"
|
||||
:selected="selectedIds.has(backup.id)"
|
||||
:highlighted="highlightedBackupId === backup.id"
|
||||
:restore-disabled="backupRestoreDisabled"
|
||||
:write-disabled="!canManageBackups"
|
||||
:write-disabled-tooltip="permissionDeniedMessage"
|
||||
:kyros-url="server.node?.instance"
|
||||
:jwt="server.node?.token"
|
||||
:show-copy-id-action="showCopyIdAction"
|
||||
:show-debug-info="showDebugInfo"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@rename="() => showRenameBackupModal(backup)"
|
||||
@restore="() => showRestoreBackupModal(backup)"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
|
||||
skipConfirmation ? deleteBackup(backup) : showDeleteBackupModal(backup)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -191,7 +211,12 @@
|
||||
|
||||
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
|
||||
<ButtonStyled type="transparent" color="red" hover-color-fill="background">
|
||||
<button type="button" @click="confirmBulkDelete">
|
||||
<button
|
||||
v-tooltip="!canManageBackups ? permissionDeniedMessage : undefined"
|
||||
type="button"
|
||||
:disabled="!canManageBackups"
|
||||
@click="confirmBulkDelete"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
@@ -244,7 +269,7 @@ import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, TrashIcon } from '@mo
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -261,6 +286,7 @@ import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModa
|
||||
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -327,6 +353,7 @@ const messages = defineMessages({
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { canManageBackups, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
const filterPillOptions = computed<FilterPillOption[]>(() => [
|
||||
{ id: 'manual', label: formatMessage(messages.filterManual) },
|
||||
@@ -344,6 +371,7 @@ const props = defineProps<{
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
const BACKUP_HIGHLIGHT_DURATION_MS = 5_000
|
||||
|
||||
defineEmits(['onDownload'])
|
||||
|
||||
@@ -476,6 +504,72 @@ const groupedBackups = computed((): BackupGroup[] => {
|
||||
})
|
||||
|
||||
const displayOrderedBackups = computed(() => groupedBackups.value.flatMap((g) => g.backups))
|
||||
const focusedBackupId = computed(() =>
|
||||
typeof route.query.backup === 'string' ? route.query.backup : null,
|
||||
)
|
||||
const highlightedBackupId = ref<string | null>(null)
|
||||
let highlightedBackupTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let lastHighlightedFocusedBackupId: string | null = null
|
||||
let lastScrolledFocusedBackupId: string | null = null
|
||||
|
||||
watch(
|
||||
[focusedBackupId, displayOrderedBackups],
|
||||
async ([backupId]) => {
|
||||
if (!backupId) {
|
||||
lastHighlightedFocusedBackupId = null
|
||||
lastScrolledFocusedBackupId = null
|
||||
clearHighlightedBackup()
|
||||
return
|
||||
}
|
||||
if (!displayOrderedBackups.value.some((backup) => backup.id === backupId)) return
|
||||
|
||||
if (lastHighlightedFocusedBackupId !== backupId) {
|
||||
lastHighlightedFocusedBackupId = backupId
|
||||
highlightBackup(backupId)
|
||||
}
|
||||
if (lastScrolledFocusedBackupId === backupId) return
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
lastScrolledFocusedBackupId = backupId
|
||||
await nextTick()
|
||||
const escapedBackupId =
|
||||
typeof CSS !== 'undefined' && CSS.escape
|
||||
? CSS.escape(backupId)
|
||||
: backupId.replaceAll('"', '\\"')
|
||||
document
|
||||
.querySelector(`[data-backup-id="${escapedBackupId}"]`)
|
||||
?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (highlightedBackupTimeout) {
|
||||
clearTimeout(highlightedBackupTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
function highlightBackup(backupId: string) {
|
||||
highlightedBackupId.value = backupId
|
||||
|
||||
if (highlightedBackupTimeout) {
|
||||
clearTimeout(highlightedBackupTimeout)
|
||||
}
|
||||
|
||||
highlightedBackupTimeout = setTimeout(() => {
|
||||
highlightedBackupId.value = null
|
||||
highlightedBackupTimeout = null
|
||||
}, BACKUP_HIGHLIGHT_DURATION_MS)
|
||||
}
|
||||
|
||||
function clearHighlightedBackup() {
|
||||
highlightedBackupId.value = null
|
||||
|
||||
if (highlightedBackupTimeout) {
|
||||
clearTimeout(highlightedBackupTimeout)
|
||||
highlightedBackupTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
@@ -496,6 +590,9 @@ const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
|
||||
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
|
||||
|
||||
const backupRestoreDisabled = computed(() => {
|
||||
if (!canManageBackups.value) {
|
||||
return permissionDeniedMessage.value
|
||||
}
|
||||
if (props.isServerRunning) {
|
||||
return 'Cannot restore backup while server is running'
|
||||
}
|
||||
@@ -509,6 +606,9 @@ const backupRestoreDisabled = computed(() => {
|
||||
})
|
||||
|
||||
const backupCreationDisabled = computed(() => {
|
||||
if (!canManageBackups.value) {
|
||||
return permissionDeniedMessage.value
|
||||
}
|
||||
const quota = server.value.backup_quota
|
||||
if (quota !== undefined) {
|
||||
const usedCount = backups.value.length ?? server.value.used_backup_quota ?? 0
|
||||
@@ -526,19 +626,37 @@ const backupCreationDisabled = computed(() => {
|
||||
})
|
||||
|
||||
const showCreateModel = () => {
|
||||
if (backupCreationDisabled.value) return
|
||||
createBackupModal.value?.show()
|
||||
}
|
||||
|
||||
function showRenameBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
if (!canManageBackups.value) return
|
||||
renameBackupModal.value?.show(backup)
|
||||
}
|
||||
|
||||
function showRestoreBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
if (backupRestoreDisabled.value) return
|
||||
restoreBackupModal.value?.show(backup)
|
||||
}
|
||||
|
||||
function showDeleteBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
if (!canManageBackups.value) return
|
||||
deleteBackupModal.value?.show(backup)
|
||||
}
|
||||
|
||||
function clearBackupFilters() {
|
||||
selectedFilters.value = []
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
if (!canManageBackups.value) return
|
||||
if (!selectedBackups.value.length) return
|
||||
deleteBackupModal.value?.showBulk(selectedBackups.value)
|
||||
}
|
||||
|
||||
async function bulkDelete(toRemove: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
|
||||
if (!canManageBackups.value) return
|
||||
if (!toRemove.length) return
|
||||
|
||||
isBulkOperating.value = true
|
||||
@@ -569,6 +687,7 @@ function useQueueDeleteFor(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
}
|
||||
|
||||
function deleteBackup(backup?: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
if (!canManageBackups.value) return
|
||||
if (!backup) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
@@ -123,6 +124,7 @@ const contentUploadSession = useUploadSessionUpload({
|
||||
})
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const { canSetup, permissionDeniedMessage } = useServerPermissions()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -136,6 +138,7 @@ const type = computed(() => {
|
||||
})
|
||||
|
||||
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
|
||||
const modpackContentQueryKey = computed(() => ['content', 'list', 'v1', serverId, 'modpack'])
|
||||
|
||||
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
|
||||
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
|
||||
@@ -150,6 +153,44 @@ const contentQuery = useQuery({
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const isModpackContentModalOpen = ref(false)
|
||||
const modpackContentQuery = useQuery({
|
||||
queryKey: modpackContentQueryKey,
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, {
|
||||
from_modpack: true,
|
||||
}),
|
||||
enabled: computed(() => isModpackContentModalOpen.value && worldId.value !== null),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
|
||||
const setupActionBusyMessage = computed(() => {
|
||||
if (!canSetup.value) return permissionDeniedMessage.value
|
||||
|
||||
const bannerCoversInstalling =
|
||||
server.value?.status === 'installing' ||
|
||||
isSyncingContent.value ||
|
||||
busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
|
||||
)
|
||||
const filteredReasons = busyReasons.value.filter((r) => {
|
||||
if (
|
||||
bannerCoversInstalling &&
|
||||
(r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content')
|
||||
)
|
||||
return false
|
||||
if (
|
||||
r.reason.id === 'servers.busy.backup-creating' ||
|
||||
r.reason.id === 'servers.busy.backup-restoring'
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
|
||||
})
|
||||
|
||||
const modpackProjectId = computed(() => {
|
||||
const spec = contentQuery.data.value?.modpack?.spec
|
||||
return spec?.platform === 'modrinth' ? spec.project_id : null
|
||||
@@ -688,12 +729,14 @@ const toggleMutation = useMutation({
|
||||
})
|
||||
|
||||
async function handleToggleEnabled(item: ContentItem) {
|
||||
if (setupActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
await toggleMutation.mutateAsync({ addon })
|
||||
}
|
||||
|
||||
async function handleDeleteItem(item: ContentItem) {
|
||||
if (setupActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
await deleteMutation.mutateAsync({ addon })
|
||||
@@ -708,6 +751,7 @@ function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAdd
|
||||
}
|
||||
|
||||
async function handleBulkDelete(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
@@ -723,6 +767,7 @@ async function handleBulkDelete(items: ContentItem[]) {
|
||||
}
|
||||
|
||||
async function handleBulkEnable(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
@@ -738,6 +783,7 @@ async function handleBulkEnable(items: ContentItem[]) {
|
||||
}
|
||||
|
||||
async function handleBulkDisable(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
@@ -760,6 +806,15 @@ const updatingProject = ref<ContentItem | null>(null)
|
||||
const updatingModpack = ref(false)
|
||||
const loadingChangelog = ref(false)
|
||||
|
||||
watch(
|
||||
() => modpackContentQuery.data.value?.addons,
|
||||
(addons) => {
|
||||
if (!isModpackContentModalOpen.value || !addons) return
|
||||
modpackAddons.value = addons
|
||||
modpackContentModal.value?.setItems(addons.map(addonToContentItem))
|
||||
},
|
||||
)
|
||||
|
||||
const updatingProjectId = computed(() => updatingProject.value?.project?.id ?? null)
|
||||
|
||||
const projectVersionsQuery = useQuery({
|
||||
@@ -797,6 +852,7 @@ const currentLoader = computed(
|
||||
)
|
||||
|
||||
function handleBrowseContent() {
|
||||
if (setupActionDisabled.value) return
|
||||
const contentType = type.value
|
||||
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
|
||||
browseServerContent({
|
||||
@@ -814,6 +870,7 @@ function handleBrowseContent() {
|
||||
}
|
||||
|
||||
function handleUploadFiles() {
|
||||
if (setupActionDisabled.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
@@ -876,15 +933,16 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
|
||||
}
|
||||
|
||||
async function handleViewModpackContent() {
|
||||
isModpackContentModalOpen.value = true
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
const data = await client.archon.content_v1.getAddons(serverId, worldId.value!, {
|
||||
from_modpack: true,
|
||||
})
|
||||
const { data } = await modpackContentQuery.refetch()
|
||||
if (!data) throw new Error('Failed to load modpack content')
|
||||
modpackAddons.value = data.addons ?? []
|
||||
const items = (data.addons ?? []).map(addonToContentItem)
|
||||
modpackContentModal.value?.show(items)
|
||||
} catch (err) {
|
||||
isModpackContentModalOpen.value = false
|
||||
modpackContentModal.value?.hide()
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -895,6 +953,7 @@ async function handleViewModpackContent() {
|
||||
}
|
||||
|
||||
async function handleModpackContentToggle(item: ContentItem) {
|
||||
if (setupActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
|
||||
@@ -903,6 +962,18 @@ async function handleModpackContentToggle(item: ContentItem) {
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
|
||||
)
|
||||
queryClient.setQueryData(
|
||||
modpackContentQueryKey.value,
|
||||
(oldData: Archon.Content.v1.Addons | undefined) =>
|
||||
oldData
|
||||
? {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((a) =>
|
||||
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
|
||||
),
|
||||
}
|
||||
: oldData,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, {
|
||||
enabled: !item.enabled,
|
||||
disabled: false,
|
||||
@@ -913,6 +984,7 @@ async function handleModpackContentToggle(item: ContentItem) {
|
||||
}
|
||||
|
||||
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
|
||||
if (setupActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
|
||||
@@ -930,6 +1002,20 @@ async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
|
||||
} else {
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
|
||||
}
|
||||
queryClient.setQueryData(
|
||||
modpackContentQueryKey.value,
|
||||
(oldData: Archon.Content.v1.Addons | undefined) =>
|
||||
oldData
|
||||
? {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((addon) =>
|
||||
items.some((item) => item.file_name === addon.filename)
|
||||
? { ...addon, disabled: !enable }
|
||||
: addon,
|
||||
),
|
||||
}
|
||||
: oldData,
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
} catch (err) {
|
||||
for (const item of items) {
|
||||
@@ -951,6 +1037,7 @@ function handleModpackUnlink() {
|
||||
}
|
||||
|
||||
async function handleModpackUnlinkConfirm() {
|
||||
if (setupActionDisabled.value) return
|
||||
try {
|
||||
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
|
||||
await contentQuery.refetch()
|
||||
@@ -964,6 +1051,7 @@ async function handleModpackUnlinkConfirm() {
|
||||
}
|
||||
|
||||
async function handleBulkUpdate(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
const addons = items
|
||||
.filter((item) => item.has_update)
|
||||
.map((item) => ({
|
||||
@@ -1063,6 +1151,7 @@ function resetUpdateState() {
|
||||
}
|
||||
|
||||
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (updatingModpack.value) {
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
|
||||
@@ -1100,6 +1189,7 @@ function setAddonInstalling(filename: string, installing: boolean) {
|
||||
}
|
||||
|
||||
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
if (setupActionDisabled.value) return
|
||||
const item = updatingProject.value
|
||||
if (item) {
|
||||
setAddonInstalling(item.file_name, true)
|
||||
@@ -1142,6 +1232,7 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
}
|
||||
|
||||
function handleModpackUpdateConfirm() {
|
||||
if (setupActionDisabled.value) return
|
||||
if (pendingModpackUpdateVersion.value) {
|
||||
contentUpdaterModal.value?.hide()
|
||||
performUpdate(pendingModpackUpdateVersion.value)
|
||||
@@ -1177,32 +1268,10 @@ provideContentManager({
|
||||
error: computed(() => contentQuery.error.value ?? null),
|
||||
modpack,
|
||||
isPackLocked: ref(false),
|
||||
isBusy: computed(() => busyReasons.value.length > 0),
|
||||
busyMessage: computed(() => {
|
||||
const bannerCoversInstalling =
|
||||
server.value?.status === 'installing' ||
|
||||
isSyncingContent.value ||
|
||||
busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' ||
|
||||
r.reason.id === 'servers.busy.syncing-content',
|
||||
)
|
||||
const filteredReasons = busyReasons.value.filter((r) => {
|
||||
if (
|
||||
bannerCoversInstalling &&
|
||||
(r.reason.id === 'servers.busy.installing' ||
|
||||
r.reason.id === 'servers.busy.syncing-content')
|
||||
)
|
||||
return false
|
||||
if (
|
||||
r.reason.id === 'servers.busy.backup-creating' ||
|
||||
r.reason.id === 'servers.busy.backup-restoring'
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
|
||||
}),
|
||||
isBusy: setupActionDisabled,
|
||||
busyMessage: setupActionBusyMessage,
|
||||
disableAddContent: computed(() => !canSetup.value),
|
||||
disableAddContentTooltip: permissionDeniedMessage.value,
|
||||
contentTypeLabel: type,
|
||||
toggleEnabled: handleToggleEnabled,
|
||||
deleteItem: handleDeleteItem,
|
||||
@@ -1253,15 +1322,24 @@ provideContentManager({
|
||||
<ReadyTransition :pending="contentReadyPending">
|
||||
<ContentPageLayout :bottom-padding="false">
|
||||
<template #modals>
|
||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||
<ConfirmUnlinkModal
|
||||
ref="modpackUnlinkModal"
|
||||
server
|
||||
:action-disabled="setupActionDisabled"
|
||||
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
|
||||
@unlink="handleModpackUnlinkConfirm"
|
||||
/>
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="modpack?.project.title"
|
||||
:modpack-icon-url="modpack?.project.icon_url"
|
||||
enable-toggle
|
||||
:action-disabled="setupActionDisabled"
|
||||
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||
@hide="isModpackContentModalOpen = false"
|
||||
/>
|
||||
<ContentUpdaterModal
|
||||
v-if="updatingProject || updatingModpack"
|
||||
@@ -1288,6 +1366,8 @@ provideContentManager({
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
:action-disabled="setupActionDisabled"
|
||||
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
@@ -1305,6 +1385,8 @@ provideContentManager({
|
||||
.join(' ')
|
||||
"
|
||||
server
|
||||
:action-disabled="setupActionDisabled"
|
||||
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
@@ -43,6 +44,7 @@ const fileUploadSession = useUploadSessionUpload({
|
||||
})
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { canWriteFiles, canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -52,6 +54,10 @@ const serverBusy = computed(() => busyReasons.value.length > 0)
|
||||
const busyTooltip = computed(() =>
|
||||
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
|
||||
)
|
||||
const fileWriteDisabled = computed(() => !canWriteFiles.value || serverBusy.value)
|
||||
const fileWriteDisabledTooltip = computed(() =>
|
||||
canWriteFiles.value ? busyTooltip.value : permissionDeniedMessage.value,
|
||||
)
|
||||
const nonBackupBusyReasons = computed(() =>
|
||||
busyReasons.value.filter(
|
||||
(r) =>
|
||||
@@ -325,6 +331,7 @@ const createMutation = useMutation({
|
||||
|
||||
// Extraction
|
||||
async function extractFile(path: string, override: boolean, dry: boolean) {
|
||||
if (fileWriteDisabled.value) return
|
||||
if (dry) {
|
||||
return await client.kyros.files_v0.extractFile(path, override, true)
|
||||
}
|
||||
@@ -346,6 +353,7 @@ async function readFileAsBlob(path: string): Promise<Blob> {
|
||||
}
|
||||
|
||||
async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (fileWriteDisabled.value) return
|
||||
await client.kyros.files_v0.updateFile(path, content)
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
}
|
||||
@@ -383,6 +391,7 @@ onMounted(async () => {
|
||||
|
||||
// Restart
|
||||
async function restartServer() {
|
||||
if (!canUsePowerActions.value) return
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
}
|
||||
|
||||
@@ -392,7 +401,7 @@ function getSessionUploadFilename(fileName: string) {
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
if (fileWriteDisabled.value || files.length === 0) return
|
||||
|
||||
try {
|
||||
const result = await fileUploadSession.uploadFiles(
|
||||
@@ -426,16 +435,20 @@ provideFileManager({
|
||||
startEditing,
|
||||
stopEditing,
|
||||
createItem: async (name, type) => {
|
||||
if (fileWriteDisabled.value) return
|
||||
const path = `${currentPath.value}/${name}`.replace('//', '/')
|
||||
await createMutation.mutateAsync({ path, type })
|
||||
},
|
||||
renameItem: async (path, newName) => {
|
||||
if (fileWriteDisabled.value) return
|
||||
await renameMutation.mutateAsync({ path, newName })
|
||||
},
|
||||
moveItem: async (source, destination) => {
|
||||
if (fileWriteDisabled.value) return
|
||||
await moveMutation.mutateAsync({ source, destination })
|
||||
},
|
||||
deleteItem: async (path, recursive) => {
|
||||
if (fileWriteDisabled.value) return
|
||||
await deleteMutation.mutateAsync({ path, recursive })
|
||||
},
|
||||
readFile,
|
||||
@@ -446,14 +459,14 @@ provideFileManager({
|
||||
cancelUpload,
|
||||
uploadState,
|
||||
refresh: refreshList,
|
||||
isBusy: serverBusy,
|
||||
busyTooltip,
|
||||
isBusy: fileWriteDisabled,
|
||||
busyTooltip: fileWriteDisabledTooltip,
|
||||
busyWarning,
|
||||
extractFile,
|
||||
prefetchDirectory,
|
||||
prefetchFile,
|
||||
showInstallFromUrl: true,
|
||||
canRestart: true,
|
||||
canRestart: canUsePowerActions.value,
|
||||
restartServer,
|
||||
canShareToMclogs: true,
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<li v-if="fetchError" class="text-red">
|
||||
<p>{{ formatMessage(messages.errorDetails) }}</p>
|
||||
<CopyCode
|
||||
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
|
||||
:text="formatFetchError(fetchError)"
|
||||
:copyable="false"
|
||||
:selectable="false"
|
||||
:language="'json'"
|
||||
@@ -143,7 +143,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else key="list">
|
||||
<div v-else key="list" class="flex flex-col gap-6">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
@@ -161,29 +161,67 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<TransitionGroup
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-3 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openMedalUpgradeModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
|
||||
<section v-if="ownedServerList.length > 0" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-xl font-semibold text-primary">
|
||||
{{ formatMessage(messages.yourServersTitle) }}
|
||||
</h2>
|
||||
<TransitionGroup
|
||||
v-if="ownedFilteredData.length > 0"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-3 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in ownedFilteredData.filter((s) => s.is_medal)"
|
||||
:key="`owned-medal-${server.server_id}`"
|
||||
v-bind="server"
|
||||
@upgrade="openMedalUpgradeModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in ownedFilteredData.filter((s) => !s.is_medal)"
|
||||
:key="`owned-${server.server_id}`"
|
||||
v-bind="server"
|
||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else class="text-secondary">
|
||||
{{ formatMessage(messages.noOwnedServersFound) }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="sharedServerList.length > 0" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-xl font-semibold text-primary">
|
||||
{{ formatMessage(messages.sharedServersTitle) }}
|
||||
</h2>
|
||||
<TransitionGroup
|
||||
v-if="sharedFilteredData.length > 0"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-3 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in sharedFilteredData.filter((s) => s.is_medal)"
|
||||
:key="`shared-medal-${server.server_id}`"
|
||||
v-bind="server"
|
||||
@upgrade="openMedalUpgradeModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in sharedFilteredData.filter((s) => !s.is_medal)"
|
||||
:key="`shared-${server.server_id}`"
|
||||
v-bind="server"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else class="text-secondary">
|
||||
{{ formatMessage(messages.noSharedServersFound) }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="filteredData.length === 0 && !isPollingForNewServers">
|
||||
{{ formatMessage(messages.noServersFound) }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -210,7 +248,6 @@ import {
|
||||
useServerBackupDownload,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -220,6 +257,7 @@ import { type ComponentPublicInstance, computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
|
||||
import type { ServerListingOwner } from '#ui/components/servers/access'
|
||||
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
|
||||
import ServerListing from '#ui/components/servers/ServerListing.vue'
|
||||
import { createHostingPurchaseIntentContext, provideHostingPurchaseIntent } from '#ui/providers'
|
||||
@@ -270,11 +308,27 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Search {count} {count, plural, one {server} other {servers}}...',
|
||||
},
|
||||
newServerButton: { id: 'servers.manage.new-server-button', defaultMessage: 'New server' },
|
||||
yourServersTitle: {
|
||||
id: 'servers.manage.your-servers-title',
|
||||
defaultMessage: 'Your servers',
|
||||
},
|
||||
sharedServersTitle: {
|
||||
id: 'servers.manage.shared-servers-title',
|
||||
defaultMessage: 'Shared servers',
|
||||
},
|
||||
checkingForNewServers: {
|
||||
id: 'servers.manage.checking-for-new-servers',
|
||||
defaultMessage: 'Checking for new servers...',
|
||||
},
|
||||
noServersFound: { id: 'servers.manage.no-servers-found', defaultMessage: 'No servers found.' },
|
||||
noOwnedServersFound: {
|
||||
id: 'servers.manage.no-owned-servers-found',
|
||||
defaultMessage: 'No servers you own match your search.',
|
||||
},
|
||||
noSharedServersFound: {
|
||||
id: 'servers.manage.no-shared-servers-found',
|
||||
defaultMessage: 'No shared servers match your search.',
|
||||
},
|
||||
handleErrorTitle: {
|
||||
id: 'servers.manage.handle-error.title',
|
||||
defaultMessage: 'An error occurred',
|
||||
@@ -399,6 +453,14 @@ const { data: regions, isLoading: regionsLoading } = useQuery({
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
const initialIndex = {
|
||||
'eu-lim': 31,
|
||||
}
|
||||
|
||||
watch(
|
||||
regions,
|
||||
(newRegions) => {
|
||||
@@ -424,14 +486,6 @@ async function fetchStock(
|
||||
return result.available
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
const initialIndex = {
|
||||
'eu-lim': 31,
|
||||
}
|
||||
|
||||
function runPingTest(
|
||||
region: Archon.Servers.v1.Region,
|
||||
index = initialIndex[region.shortcode] ?? 1,
|
||||
@@ -568,19 +622,15 @@ const serverList = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!showServersListLoading.value && serverList.value.length === 0 && !isPollingForNewServers.value,
|
||||
!showServersListLoading.value &&
|
||||
ownedServerList.value.length === 0 &&
|
||||
sharedServerList.value.length === 0 &&
|
||||
!isPollingForNewServers.value,
|
||||
)
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (serverList.value.length === 0) return null
|
||||
return new Fuse(serverList.value, {
|
||||
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
})
|
||||
})
|
||||
type ServerWithOwner = Archon.Servers.v0.Server & { owner?: ServerListingOwner }
|
||||
|
||||
function isSetToCancel(server: Archon.Servers.v0.Server): boolean {
|
||||
return (
|
||||
@@ -617,14 +667,56 @@ function filesExpired(server: Archon.Servers.v0.Server): boolean {
|
||||
return new Date() > thirtyDaysLater
|
||||
}
|
||||
|
||||
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
const base = !searchInput.value.trim()
|
||||
? sortServers(serverList.value)
|
||||
: fuse.value
|
||||
? sortServers(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
return base.filter((server) => !filesExpired(server))
|
||||
})
|
||||
function isServerOwnedByCurrentUser(server: Archon.Servers.v0.Server): boolean {
|
||||
return server.owner_id === auth.user.value?.id
|
||||
}
|
||||
|
||||
function getServerOwner(server: Archon.Servers.v0.Server): ServerListingOwner | undefined {
|
||||
const owner = serverResponse.value?.users?.[server.owner_id]
|
||||
if (!owner) return undefined
|
||||
|
||||
return {
|
||||
username: owner.username,
|
||||
avatarUrl: owner.avatar_url ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const ownedServerList = computed<ServerWithOwner[]>(() =>
|
||||
serverList.value.filter((server) => !filesExpired(server) && isServerOwnedByCurrentUser(server)),
|
||||
)
|
||||
const sharedServerList = computed<ServerWithOwner[]>(() =>
|
||||
serverList.value
|
||||
.filter((server) => !filesExpired(server) && !isServerOwnedByCurrentUser(server))
|
||||
.map((server) => ({
|
||||
...server,
|
||||
owner: getServerOwner(server),
|
||||
})),
|
||||
)
|
||||
|
||||
function filterServersBySearch(servers: ServerWithOwner[]): ServerWithOwner[] {
|
||||
const normalizedSearch = searchInput.value.trim()
|
||||
if (!normalizedSearch) return sortServers(servers) as ServerWithOwner[]
|
||||
|
||||
const fuse = new Fuse(servers, {
|
||||
keys: ['name', 'loader', 'mc_version', 'game', 'state', 'owner.username'],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
})
|
||||
return sortServers(
|
||||
fuse.search(normalizedSearch).map((result) => result.item),
|
||||
) as ServerWithOwner[]
|
||||
}
|
||||
|
||||
const ownedFilteredData = computed<ServerWithOwner[]>(() =>
|
||||
filterServersBySearch(ownedServerList.value),
|
||||
)
|
||||
const sharedFilteredData = computed<ServerWithOwner[]>(() =>
|
||||
filterServersBySearch(sharedServerList.value),
|
||||
)
|
||||
const filteredData = computed<ServerWithOwner[]>(() => [
|
||||
...ownedFilteredData.value,
|
||||
...sharedFilteredData.value,
|
||||
])
|
||||
|
||||
// Start polling only after initial data is available so the baseline is correct
|
||||
watch(serverResponse, (response) => {
|
||||
@@ -688,6 +780,10 @@ function handleError(err: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown) {
|
||||
return error instanceof Error && error.message ? error.message : 'Unknown error'
|
||||
}
|
||||
|
||||
function handleSignIn() {
|
||||
void auth.requestSignIn('/hosting/manage')
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
|
||||
import { useModrinthServersConsole } from '#ui/composables'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
@@ -64,6 +65,7 @@ const {
|
||||
powerStateDetails: _powerStateDetails,
|
||||
} = injectModrinthServerContext()
|
||||
const modrinthServersConsole = useModrinthServersConsole()
|
||||
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
watch(
|
||||
() => props.showAdvancedDebugInfo,
|
||||
@@ -107,6 +109,7 @@ const dismissCrash = () => {
|
||||
provideConsoleManager({
|
||||
logLines: modrinthServersConsole.output,
|
||||
sendCommand: (cmd: string) => {
|
||||
if (!canUsePowerActions.value) return
|
||||
try {
|
||||
client.archon.sockets.send(serverId, { event: 'command', cmd })
|
||||
} catch (error) {
|
||||
@@ -114,7 +117,12 @@ provideConsoleManager({
|
||||
}
|
||||
},
|
||||
showCommandInput: true,
|
||||
disableCommandInput: computed(() => serverPowerState.value !== 'running'),
|
||||
disableCommandInput: computed(
|
||||
() => !canUsePowerActions.value || serverPowerState.value !== 'running',
|
||||
),
|
||||
disableCommandInputTooltip: computed(() =>
|
||||
canUsePowerActions.value ? undefined : permissionDeniedMessage.value,
|
||||
),
|
||||
loading: computed(
|
||||
() =>
|
||||
!isConnected.value ||
|
||||
@@ -122,6 +130,7 @@ provideConsoleManager({
|
||||
isWsAuthIncorrect.value,
|
||||
),
|
||||
onClear: async () => {
|
||||
if (!canUsePowerActions.value) return
|
||||
modrinthServersConsole.clear()
|
||||
try {
|
||||
await client.kyros.logs_v1.clear()
|
||||
@@ -129,6 +138,10 @@ provideConsoleManager({
|
||||
console.error('Failed to clear server logs:', error)
|
||||
}
|
||||
},
|
||||
clearDisabled: computed(() => !canUsePowerActions.value),
|
||||
clearDisabledTooltip: computed(() =>
|
||||
canUsePowerActions.value ? undefined : permissionDeniedMessage.value,
|
||||
),
|
||||
shareDisabled: computed(() => !isConnected.value),
|
||||
emptyStateType: 'server',
|
||||
crashAnalysis,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="filteredNotices.length > 0"
|
||||
class="relative mx-auto mb-4 flex w-full min-w-0 flex-col gap-3 px-6"
|
||||
:class="{
|
||||
'max-w-[1280px]': isNuxt,
|
||||
'max-w-[1280px]': constrainWidth,
|
||||
}"
|
||||
>
|
||||
<ServerNotice
|
||||
@@ -107,7 +107,7 @@
|
||||
}"
|
||||
:class="[
|
||||
'server-panel-' + revealState,
|
||||
isNuxt ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
|
||||
constrainWidth ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
|
||||
]"
|
||||
>
|
||||
<template v-if="revealState !== 'pending' || isOnboarding">
|
||||
@@ -344,7 +344,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { ModrinthApiError } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckIcon,
|
||||
@@ -360,9 +360,9 @@ import {
|
||||
SettingsIcon,
|
||||
TransferIcon,
|
||||
TriangleAlertIcon,
|
||||
UsersIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useStorage, useTimeoutFn } from '@vueuse/core'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -384,6 +384,7 @@ import {
|
||||
} from '#ui/components/servers/server-header'
|
||||
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
|
||||
import {
|
||||
hasServerPermission,
|
||||
useDebugLogger,
|
||||
useLoadingBarToken,
|
||||
useModrinthServersConsole,
|
||||
@@ -394,6 +395,7 @@ import {
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
|
||||
import { useServerPanelSync } from '#ui/composables/server-panel-sync'
|
||||
import type { LogLine } from '#ui/layouts/shared/console'
|
||||
import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings'
|
||||
import {
|
||||
@@ -401,6 +403,8 @@ import {
|
||||
injectNotificationManager,
|
||||
provideServerSettingsModal,
|
||||
} from '#ui/providers'
|
||||
import type { ServerStats } from '#ui/providers/server-context'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
import {
|
||||
pendingServerContentInstallsEvent,
|
||||
@@ -442,6 +446,7 @@ const props = withDefaults(
|
||||
worldId: string | null
|
||||
type: 'mod' | 'plugin' | 'datapack'
|
||||
}) => void | Promise<void>
|
||||
constrainWidth?: boolean
|
||||
}>(),
|
||||
{
|
||||
showCopyIdAction: false,
|
||||
@@ -456,6 +461,7 @@ const props = withDefaults(
|
||||
navigateToServers: undefined,
|
||||
browseModpacks: undefined,
|
||||
browseContent: undefined,
|
||||
constrainWidth: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -492,7 +498,7 @@ const DISABLE_LOADING_ANIM = true
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
const constrainWidth = computed(() => props.constrainWidth)
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -561,6 +567,11 @@ const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQue
|
||||
worldId,
|
||||
)
|
||||
|
||||
const { disconnect: disconnectPanelSync } = useServerPanelSync({
|
||||
serverId: computed(() => props.serverId),
|
||||
worldId,
|
||||
})
|
||||
|
||||
const { image: serverImage } = useServerImage(
|
||||
props.serverId,
|
||||
computed(() => serverData.value?.upstream ?? null),
|
||||
@@ -672,6 +683,7 @@ const {
|
||||
serverId: computed(() => props.serverId),
|
||||
worldId,
|
||||
server: serverData,
|
||||
serverFull,
|
||||
isSyncingContent,
|
||||
extraBusyReasons: backupsBusy,
|
||||
setDisconnectedOnAuthIncorrect: false,
|
||||
@@ -682,6 +694,10 @@ const {
|
||||
})
|
||||
|
||||
const isUploading = computed(() => uploadState.value.isUploading)
|
||||
const canSetup = computed(() =>
|
||||
hasServerPermission(serverData.value?.current_user_permissions ?? 0, 'SETUP'),
|
||||
)
|
||||
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isUploading.value) {
|
||||
@@ -714,7 +730,7 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
type CachedWsState = {
|
||||
stats: Stats
|
||||
stats: ServerStats
|
||||
cpuData: number[]
|
||||
ramData: number[]
|
||||
powerState: Archon.Websocket.v0.PowerState
|
||||
@@ -822,6 +838,12 @@ const navLinks = computed<Tab[]>(() => [
|
||||
icon: DatabaseBackupIcon,
|
||||
subpages: [],
|
||||
},
|
||||
{
|
||||
label: 'Access',
|
||||
href: `/hosting/manage/${props.serverId}/access`,
|
||||
icon: UsersIcon,
|
||||
subpages: [],
|
||||
},
|
||||
...props.additionalTabs,
|
||||
])
|
||||
|
||||
@@ -932,6 +954,13 @@ function loadTallyScript() {
|
||||
|
||||
async function handleContentRetry() {
|
||||
if (!worldId.value) return
|
||||
if (!canSetup.value) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: permissionDeniedMessage.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await client.archon.content_v1.repair(props.serverId, worldId.value)
|
||||
} catch (err) {
|
||||
@@ -1361,6 +1390,7 @@ const cleanup = () => {
|
||||
saveWsStateToCache()
|
||||
|
||||
cleanupCoreRuntime(props.serverId)
|
||||
disconnectPanelSync()
|
||||
|
||||
isReconnecting.value = false
|
||||
isLoading.value = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
|
||||
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
|
||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
||||
export { default as ServersManageContentPage } from './hosting/manage/content.vue'
|
||||
|
||||
Reference in New Issue
Block a user