feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 16:58:01 +01:00
committed by GitHub
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
@@ -26,8 +26,9 @@
>
<StyledInput
v-model="commandInput"
v-tooltip="disableInput ? disableInputTooltip : undefined"
:icon="TerminalSquareIcon"
:placeholder="disableInput ? 'Server is not running' : 'Send a command'"
:placeholder="disableInput ? disabledInputPlaceholder : 'Send a command'"
:disabled="disableInput"
wrapper-class="w-full"
input-class="!h-10"
@@ -51,6 +52,8 @@ const props = withDefaults(
scrollback?: number
showInput?: boolean
disableInput?: boolean
disableInputTooltip?: string
disabledInputPlaceholder?: string
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
loading?: boolean
@@ -59,6 +62,8 @@ const props = withDefaults(
scrollback: Infinity,
showInput: false,
disableInput: false,
disableInputTooltip: undefined,
disabledInputPlaceholder: 'Server is not running',
fullscreen: false,
emptyStateType: undefined,
loading: false,
@@ -215,6 +220,7 @@ watch(
)
const submitCommand = () => {
if (props.disableInput) return
const cmd = commandInput.value.trim()
if (!cmd) return
emit('command', cmd)
@@ -349,20 +349,19 @@ const fontSize = computed(() => {
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child,
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child > svg,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child {
> svg {
display: block;
width: var(--_icon-size, 1rem);
height: var(--_icon-size, 1rem);
min-width: var(--_icon-size, 1rem);
min-height: var(--_icon-size, 1rem);
}
+38 -7
View File
@@ -23,6 +23,10 @@
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
:spellcheck="searchSpellcheck"
wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass"
class="relative z-[1]"
@@ -73,7 +77,7 @@
<slot name="selected" :label="triggerText">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<div class="flex shrink-0 items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
@@ -97,6 +101,7 @@
:class="[
props.dropdownClass,
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
props.dropdownClass,
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@@ -174,7 +179,7 @@
</div>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
<div v-else-if="searchQuery" class="p-4 text-center text-sm text-secondary">
{{ noOptionsMessage }}
</div>
@@ -276,12 +281,19 @@ const props = withDefaults(
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
dropdownClass?: string
dropdownMinWidth?: string
minSearchLengthToOpen?: number
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
/** Select the searchable input text when the field receives focus */
selectSearchTextOnFocus?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
searchAutocomplete?: string
searchAutocorrect?: 'on' | 'off'
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -293,6 +305,7 @@ const props = withDefaults(
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
noOptionsMessage: 'No results found',
minSearchLengthToOpen: 0,
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
@@ -377,6 +390,10 @@ const triggerText = computed(() => {
return props.placeholder
})
const hasMinimumSearchLength = computed(
() => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen,
)
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
@@ -413,8 +430,7 @@ function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: num
item.class,
{
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
isSelected,
'bg-highlight-green text-green hover:bg-highlight-green focus:bg-highlight-green': isSelected,
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
@@ -583,7 +599,8 @@ function destroyOptionsOverlayScrollbars() {
}
async function openDropdown() {
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value)
return
isOpen.value = true
emit('open')
@@ -628,7 +645,11 @@ function handleTriggerClick(event: MouseEvent) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
const isSelected = props.listbox && option.value === props.modelValue
if (isSelected) return
if (isSelected) {
focusedIndex.value = index
if (option.type !== 'link') closeDropdown()
return
}
focusedIndex.value = index
@@ -782,6 +803,10 @@ function handleSearchKeydown(event: KeyboardEvent) {
function handleSearchInput() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
if (!hasMinimumSearchLength.value) {
closeDropdown()
return
}
if (!isOpen.value) {
openDropdown()
}
@@ -906,10 +931,16 @@ watch(hasDropdownContent, (value) => {
}
})
watch(hasMinimumSearchLength, (canOpen) => {
if (!canOpen) {
closeDropdown()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {
if (props.searchable && props.syncWithSelection && !isOpen.value) {
if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) {
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
@@ -109,7 +109,7 @@
<button
ref="addMenuTrigger"
type="button"
:class="addButtonClass"
:class="addButtonClass ?? '!border'"
:aria-expanded="isAddMenuOpen"
aria-haspopup="menu"
@click="handleAddMenuTriggerClick"
@@ -262,58 +262,82 @@
:style="activeCategoryOptionsListStyle"
>
<div
v-for="{ option, index } in renderedVisibleActiveCategoryOptions"
:key="`${activeCategory.key}-${option.value}`"
v-for="{ item, index } in renderedVisibleActiveCategoryOptions"
:key="getActiveCategoryItemKey(item, index)"
:class="shouldVirtualizeActiveCategoryOptions ? 'absolute left-0 right-0' : undefined"
:style="getActiveCategoryOptionWrapperStyle(index)"
>
<div
v-if="isDropdownFilterSectionHeader(item)"
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
:class="item.class"
>
<span class="flex min-w-0 items-center gap-2">
<component
:is="item.icon"
v-if="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="min-w-0 truncate">{{ item.label }}</span>
</span>
<button
v-if="hasSelectableSectionHeaderOptions(item)"
type="button"
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
@click="toggleSectionHeaderOptions(item)"
>
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
</button>
</div>
<button
v-else
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 border-0 px-4 py-3.5 text-left text-contrast shadow-none transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%] focus-visible:outline-none"
:class="[
shouldVirtualizeActiveCategoryOptions ? 'h-12' : undefined,
{
'brightness-[115%]': option.selected,
'pointer-events-none cursor-not-allowed opacity-50': option.disabled,
'brightness-[115%]': item.selected,
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
},
]"
:aria-disabled="option.disabled || undefined"
:aria-checked="option.selected"
:aria-disabled="item.disabled || undefined"
:aria-checked="item.selected"
role="checkbox"
@click="toggleFilterOption(activeCategory.key, option)"
@click="toggleFilterOption(activeCategory.key, item)"
>
<span
v-if="checkboxPosition === 'left'"
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
:class="
option.selected
item.selected
? 'border-button-border bg-brand text-brand-inverted'
: 'border-surface-5 bg-surface-2'
"
>
<CheckIcon v-if="option.selected" aria-hidden="true" stroke-width="3" />
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
</span>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<slot
v-if="$slots.option"
name="option"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
:index="index"
></slot>
<template v-else>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="option.selected ? 'text-contrast' : 'text-primary'"
:class="item.selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
{{ item.label }}
</span>
<slot
name="option-right"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
></slot>
</template>
</div>
@@ -321,7 +345,7 @@
v-if="checkboxPosition === 'right'"
class="flex shrink-0 items-center justify-center text-brand"
>
<CheckIcon v-if="option.selected" aria-hidden="true" class="size-5" />
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
</span>
</button>
</div>
@@ -353,12 +377,12 @@ import {
} from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import type { ComponentPublicInstance, CSSProperties } from 'vue'
import type { Component, ComponentPublicInstance, CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import ButtonStyled from './ButtonStyled.vue'
import MultiSelect, { type MultiSelectOption } from './MultiSelect.vue'
import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue'
import StyledInput from './StyledInput.vue'
export type DropdownFilterBarOption = {
@@ -368,10 +392,20 @@ export type DropdownFilterBarOption = {
disabled?: boolean
}
export type DropdownFilterBarSectionHeader = {
type: 'section-header'
label: string
key?: string
icon?: Component
class?: string
}
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
export type DropdownFilterBarCategory = {
key: string
label: string
options: DropdownFilterBarOption[]
options: DropdownFilterBarItem[]
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
searchPlaceholder?: string
@@ -388,8 +422,12 @@ type RenderedDropdownFilterBarOption = DropdownFilterBarOption & {
selected: boolean
}
type RenderedDropdownFilterBarItem =
| RenderedDropdownFilterBarOption
| DropdownFilterBarSectionHeader
type VisibleDropdownFilterBarOption = {
option: RenderedDropdownFilterBarOption
item: RenderedDropdownFilterBarItem
index: number
}
@@ -464,6 +502,7 @@ const props = withDefaults(
showClear?: boolean
showLabel?: boolean
useFilterIcon?: boolean
applyImmediately?: boolean
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
@@ -478,6 +517,7 @@ const props = withDefaults(
showClear: false,
showLabel: true,
useFilterIcon: false,
applyImmediately: false,
showPreviewFilterIcon: false,
emptyOptionsLabel: 'No options available.',
emptySearchLabel: 'No options found.',
@@ -519,6 +559,18 @@ let pendingCategoryTimeout: ReturnType<typeof setTimeout> | null = null
let previousMousePosition: Point | null = null
let addMenuPositionRafId: number | null = null
function isDropdownFilterSectionHeader(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarSectionHeader {
return 'type' in item && item.type === 'section-header'
}
function isDropdownFilterOption(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarOption {
return !isDropdownFilterSectionHeader(item)
}
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
const source = isAddMenuOpen.value ? 'draft' : 'committed'
return props.categories.map((category) => {
@@ -569,15 +621,32 @@ const filteredActiveCategoryOptions = computed(() => {
return activeCategory.value.options
}
return activeCategory.value.options.filter((option) => {
if (option.label.toLowerCase().includes(query)) {
return true
const items: DropdownFilterBarItem[] = []
let pendingSectionHeader: DropdownFilterBarSectionHeader | null = null
for (const option of activeCategory.value.options) {
if (isDropdownFilterSectionHeader(option)) {
pendingSectionHeader = option
continue
}
if (option.value.toLowerCase().includes(query)) {
return true
const matches =
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
(option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false)
if (!matches) {
continue
}
return option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false
})
if (pendingSectionHeader) {
items.push(pendingSectionHeader)
pendingSectionHeader = null
}
items.push(option)
}
return items
})
const shouldVirtualizeActiveCategoryOptions = computed(
@@ -599,11 +668,13 @@ const {
})
const renderedVisibleActiveCategoryOptions = computed<VisibleDropdownFilterBarOption[]>(() =>
visibleActiveCategoryOptions.value.map((option, offset) => ({
option: {
...option,
selected: activeCategorySelectedValueSet.value.has(option.value),
},
visibleActiveCategoryOptions.value.map((item, offset) => ({
item: isDropdownFilterSectionHeader(item)
? item
: {
...item,
selected: activeCategorySelectedValueSet.value.has(item.value),
},
index: activeCategoryOptionsVisibleRange.value.start + offset,
})),
)
@@ -721,14 +792,14 @@ function areSelectedFiltersEqual(
}
function getOptionsWithSelectedValues(
options: DropdownFilterBarOption[],
options: DropdownFilterBarItem[],
selectedValues: string[],
): DropdownFilterBarOption[] {
): DropdownFilterBarItem[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const knownValues = new Set(options.filter(isDropdownFilterOption).map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
@@ -748,15 +819,17 @@ function getCategorySyntheticValues(categoryKey: string): Set<string> {
return category ? getCategorySyntheticValueSet(category) : new Set()
}
function getVisiblePreviewOptions(
category: DropdownFilterBarCategory,
): MultiSelectOption<string>[] {
return category.options.map((option) => ({
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
})) as MultiSelectOption<string>[]
function getVisiblePreviewOptions(category: DropdownFilterBarCategory): MultiSelectItem<string>[] {
return category.options.map((option) =>
isDropdownFilterSectionHeader(option)
? option
: {
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
},
) as MultiSelectItem<string>[]
}
function getPreviewOptionLabel(
@@ -764,7 +837,7 @@ function getPreviewOptionLabel(
selectedValue: string,
): string | undefined {
return [...(category.syntheticOptions ?? []), ...category.options].find(
(option) => option.value === selectedValue,
(option) => isDropdownFilterOption(option) && option.value === selectedValue,
)?.label
}
@@ -797,6 +870,9 @@ function setSelectedValues(
if (isAddMenuOpen.value && activeCategoryKey.value === categoryKey) {
scheduleSubmenuPositionUpdate()
}
if (props.applyImmediately) {
emit('update:modelValue', nextFilters)
}
} else {
emit('update:modelValue', nextFilters)
}
@@ -950,6 +1026,70 @@ function toggleFilterOption(categoryKey: string, option: DropdownFilterBarOption
toggleFilterValue(categoryKey, option.value, !isFilterValueSelected(categoryKey, option.value))
}
function getActiveCategoryItemKey(item: RenderedDropdownFilterBarItem, index: number) {
return isDropdownFilterSectionHeader(item)
? (item.key ?? `${activeCategory.value?.key ?? 'category'}-section-${item.label}-${index}`)
: `${activeCategory.value?.key ?? 'category'}-${item.value}`
}
function getSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return []
}
const sectionHeaderIndex = category.options.findIndex((item) => item === sectionHeader)
if (sectionHeaderIndex === -1) {
return []
}
const options: DropdownFilterBarOption[] = []
for (let index = sectionHeaderIndex + 1; index < category.options.length; index += 1) {
const item = category.options[index]
if (!item || isDropdownFilterSectionHeader(item)) {
break
}
if (!item.disabled) {
options.push(item)
}
}
return options
}
function hasSelectableSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
return getSectionHeaderOptions(sectionHeader).length > 1
}
function areSectionHeaderOptionsSelected(sectionHeader: DropdownFilterBarSectionHeader) {
const options = getSectionHeaderOptions(sectionHeader)
return (
options.length > 0 &&
options.every((option) => activeCategorySelectedValueSet.value.has(option.value))
)
}
function toggleSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return
}
const options = getSectionHeaderOptions(sectionHeader)
if (options.length === 0) {
return
}
const optionValues = options.map((option) => option.value)
const optionValueSet = new Set(optionValues)
const currentValues = activeCategorySelectedValues.value
const nextValues = areSectionHeaderOptionsSelected(sectionHeader)
? currentValues.filter((value) => !optionValueSet.has(value))
: [...currentValues, ...optionValues.filter((value) => !currentValues.includes(value))]
setSelectedValues(category.key, nextValues, 'draft')
}
function getActiveCategoryOptionWrapperStyle(index: number): CSSProperties | undefined {
if (!shouldVirtualizeActiveCategoryOptions.value) {
return undefined
@@ -1076,9 +1216,13 @@ function getPreviewSelectedValues(categoryKey: string): string[] {
}
function setPreviewSelectedValues(categoryKey: string, values: string[]) {
const normalizedValues = normalizeSelectedValues(values)
previewSelectedValueDrafts.value = {
...previewSelectedValueDrafts.value,
[categoryKey]: normalizeSelectedValues(values),
[categoryKey]: normalizedValues,
}
if (props.applyImmediately) {
setSelectedValues(categoryKey, normalizedValues)
}
}
@@ -31,11 +31,15 @@ const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCo
const floatingActionBarId = Symbol('floating-action-bar')
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
() => pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)',
const leftOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)'),
)
const rightOffset = computed(
() => pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)',
const rightOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)'),
)
const barStyle = computed(() => ({
zIndex: zIndex.value,
@@ -2,6 +2,7 @@
<div class="joined-buttons">
<ButtonStyled :color="color" :size="size">
<button
v-tooltip="primaryTooltip"
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
:disabled="primaryDisabledResolved"
@click="handlePrimaryAction"
@@ -15,6 +16,7 @@
class="btn-dropdown-animation !w-10"
:options="dropdownOptions"
:disabled="dropdownDisabledResolved"
:tooltip="dropdownTooltip"
>
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
@@ -53,6 +55,8 @@ interface Props {
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
primaryTooltip?: string
dropdownTooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -62,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
primaryDisabled: undefined,
dropdownDisabled: undefined,
primaryMuted: false,
primaryTooltip: undefined,
dropdownTooltip: undefined,
})
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
@@ -74,6 +74,7 @@
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
v-tooltip="leftButtonConfig.tooltip"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
@@ -84,6 +85,7 @@
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
v-tooltip="rightButtonConfig.tooltip"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@@ -128,6 +130,7 @@ export interface StageButtonConfig {
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
loading?: boolean
tooltip?: string
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
@@ -28,6 +28,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:maxlength="maxlength"
:rows="rows"
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
@@ -55,6 +58,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:inputmode="inputmode"
:maxlength="maxlength"
:min="min"
@@ -124,6 +130,9 @@ const props = withDefaults(
id?: string
name?: string
autocomplete?: string
autocorrect?: 'on' | 'off'
autocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
spellcheck?: boolean
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
maxlength?: number
min?: number
+56 -2
View File
@@ -62,7 +62,56 @@
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<TransitionGroup
v-if="rowTransitionName && !virtualized"
:name="rowTransitionName"
tag="tbody"
>
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
</template>
</TransitionGroup>
<tbody v-else :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
@@ -83,7 +132,7 @@
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
@@ -169,6 +218,7 @@ const props = withDefaults(
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
rowTransitionName?: string
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
@@ -269,6 +319,10 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
return rowIndex
}
function getRowClass(rowIndex: number): string {
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function isSelected(row: T): boolean {
return selectedIdSet.value.has(getSelectionId(row))
}
@@ -96,6 +96,14 @@ const { data: regionsData } = useQuery({
queryFn: () => archon.servers_v1.getRegions(),
})
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
watch(
customerData,
(newCustomer) => {
@@ -136,14 +144,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,
@@ -5,8 +5,10 @@
}}</span>
<Combobox
v-model="ctx.modpackSearchProjectId.value"
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
:options="ctx.modpackSearchOptions.value"
searchable
:disabled="ctx.finishDisabled.value"
:search-placeholder="formatMessage(messages.searchModpackPlaceholder)"
:no-options-message="
searchLoading
@@ -29,13 +31,23 @@
</div>
<div class="flex gap-3">
<ButtonStyled type="outlined">
<button class="flex-1" @click="triggerFileInput">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="triggerFileInput"
>
<ImportIcon />
{{ formatMessage(messages.importModpack) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="ctx.browseModpacks()"
>
<CompassIcon />
{{ formatMessage(messages.browseModpacks) }}
</button>
@@ -87,6 +99,8 @@ const messages = defineMessages({
})
function proceedWithModpack() {
if (ctx.finishDisabled.value) return
debug('proceedWithModpack:', {
flowType: ctx.flowType,
modpackSelection: ctx.modpackSelection.value,
@@ -196,6 +210,8 @@ watch(
)
async function triggerFileInput() {
if (ctx.finishDisabled.value) return
const picked = await filePicker.pickModpackFile({
readFile: ctx.flowType !== 'instance',
})
@@ -186,6 +186,8 @@ export interface CreationFlowContextValue {
// Loading state (set when finish() is called, cleared on reset)
loading: Ref<boolean>
finishDisabled: ComputedRef<boolean>
finishDisabledTooltip: ComputedRef<string | undefined>
// Backup state (set by InlineBackupCreator in reset-server flow)
isBackingUp: Ref<boolean>
@@ -232,6 +234,8 @@ export interface CreationFlowOptions {
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
finishDisabled?: ComputedRef<boolean>
finishDisabledTooltip?: ComputedRef<string | undefined>
}
export function createCreationFlowContext(
@@ -257,6 +261,8 @@ export function createCreationFlowContext(
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const getLoaderManifest = options.getLoaderManifest ?? null
const finishDisabled = options.finishDisabled ?? computed(() => false)
const finishDisabledTooltip = options.finishDisabledTooltip ?? computed(() => undefined)
const setupType = ref<SetupType | null>(null)
const isImportMode = ref(false)
@@ -502,6 +508,8 @@ export function createCreationFlowContext(
}
function finish() {
if (finishDisabled.value) return
debug('finish() called, state:', {
setupType: setupType.value,
selectedLoader: selectedLoader.value,
@@ -585,6 +593,8 @@ export function createCreationFlowContext(
importSearchQuery,
hardReset,
loading,
finishDisabled,
finishDisabledTooltip,
isBackingUp,
cancelBackup,
modal,
@@ -10,7 +10,7 @@
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import MultiStageModal from '../../base/MultiStageModal.vue'
@@ -38,6 +38,8 @@ const props = withDefaults(
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
finishDisabled?: boolean
finishDisabledTooltip?: string
}>(),
{
type: 'world',
@@ -78,6 +80,8 @@ const ctx = createCreationFlowContext(
searchModpacks: props.searchModpacks,
getProjectVersions: props.getProjectVersions,
getLoaderManifest: props.getLoaderManifest,
finishDisabled: computed(() => props.finishDisabled ?? false),
finishDisabledTooltip: computed(() => props.finishDisabledTooltip),
},
)
provideCreationFlowContext(ctx)
@@ -46,12 +46,14 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: PlusIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled,
disabled: disabled || ctx.finishDisabled.value,
loading: ctx.loading.value,
tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => ctx.finish(),
}
}
const finishDisabled = !goesToNextStage && ctx.finishDisabled.value
return {
label: ctx.formatMessage(
goesToNextStage ? commonMessages.continueButton : creationFlowMessages.finishButton,
@@ -59,7 +61,8 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: goesToNextStage ? RightArrowIcon : null,
iconPosition: 'after' as const,
color: goesToNextStage ? undefined : ('brand' as const),
disabled,
disabled: disabled || finishDisabled,
tooltip: finishDisabled ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => {
if (goesToNextStage) {
ctx.modal.value?.nextStage()
@@ -51,8 +51,10 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value,
disabled:
isForwardBlocked(ctx) || ctx.isBackingUp.value || (isFinish && ctx.finishDisabled.value),
loading: isFinish && ctx.loading.value,
tooltip: isFinish && ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => {
if (isFinish) {
ctx.finish()
@@ -38,7 +38,8 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: DownloadIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled: count === 0,
disabled: count === 0 || ctx.finishDisabled.value,
tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => ctx.finish(),
}
},
+1
View File
@@ -8,6 +8,7 @@ export * from './content'
export * from './external_files'
export * from './modal'
export * from './nav'
export * from './notifications'
export * from './page'
export * from './project'
export * from './search'
+93 -1
View File
@@ -136,6 +136,7 @@
import { XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useDebugLogger } from '../../composables/debug-logger'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
@@ -144,6 +145,7 @@ import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('NewModal')
const modalBehavior = injectModalBehavior(null)
const {
@@ -233,54 +235,138 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
debug('show: start', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
hasEvent: !!event,
})
props.onShow?.()
debug('show: after onShow', { header: props.header })
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
debug('show: before open=true', {
header: props.header,
wasEmpty,
stackDepth: stackDepth.value,
})
open.value = true
debug('show: after open=true', {
header: props.header,
open: open.value,
modalBodyExists: !!modalBodyRef.value,
})
previousFocusEl = document.activeElement
debug('show: previous focus captured', {
header: props.header,
previousFocusTag: previousFocusEl instanceof HTMLElement ? previousFocusEl.tagName : null,
previousFocusClass: previousFocusEl instanceof HTMLElement ? previousFocusEl.className : null,
})
pushModal()
debug('show: after pushModal', { header: props.header, stackSize: modalStackSize() })
if (wasEmpty) modalBehavior?.onShow?.()
debug('show: after modalBehavior onShow', { header: props.header })
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleWindowKeyDown)
window.addEventListener('mousedown', updateMousePosition)
debug('show: listeners attached', { header: props.header })
if (event) {
updateMousePosition(event)
} else {
mouseX.value = Math.round(window.innerWidth / 2)
mouseY.value = Math.round(window.innerHeight / 2)
}
debug('show: mouse position set', {
header: props.header,
mouseX: mouseX.value,
mouseY: mouseY.value,
})
setTimeout(() => {
debug('show: timeout before visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
visible.value = true
debug('show: timeout after visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
nextTick(() => {
debug('show: nextTick focus start', {
header: props.header,
modalBodyExists: !!modalBodyRef.value,
})
const focusable = getFocusableElements()
debug('show: focusable elements', {
header: props.header,
count: focusable.length,
firstTag: focusable[0]?.tagName,
})
if (focusable.length > 0) {
focusable[0].focus()
} else {
modalBodyRef.value?.focus()
}
debug('show: nextTick focus done', { header: props.header })
})
}, 50)
debug('show: end', { header: props.header })
}
function hide() {
if (props.disableClose) return
debug('hide: start', {
header: props.header,
open: open.value,
visible: visible.value,
disableClose: props.disableClose,
stackSize: modalStackSize(),
})
if (props.disableClose) {
debug('hide: ignored disableClose', { header: props.header })
return
}
props.onHide?.()
debug('hide: after onHide', { header: props.header })
visible.value = false
debug('hide: after visible=false', { header: props.header, visible: visible.value })
popModal()
debug('hide: after popModal', { header: props.header, stackSize: modalStackSize() })
if (modalStackSize() === 0) {
modalBehavior?.onHide?.()
document.body.style.overflow = ''
}
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
debug('hide: listeners removed', { header: props.header })
if (previousFocusEl instanceof HTMLElement) {
debug('hide: restoring focus', {
header: props.header,
previousFocusTag: previousFocusEl.tagName,
previousFocusClass: previousFocusEl.className,
})
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
debug('hide: timeout before open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
open.value = false
debug('hide: timeout after open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
}, 300)
debug('hide: end', { header: props.header })
}
defineExpose({
@@ -308,6 +394,12 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
}
onUnmounted(() => {
debug('unmounted', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
})
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
@@ -13,7 +13,38 @@
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<NotificationToast
v-if="item.toast"
:type="item.toast.type"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
:entity-icon-url="item.toast.entityIconUrl"
:status-text="item.toast.statusText"
:progress="item.toast.progress"
:waiting="item.toast.waiting"
@accept="handleToastAction(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@open-actor="item.toast.onOpenActor?.()"
@open-instance="handleToastAction(item, item.toast.onOpenInstance)"
/>
<div v-else-if="isDownloadNotification(item)" class="flex flex-col gap-4">
<NotificationToast
v-for="progressItem in downloadToastItems(item)"
:key="progressItem.id"
type="instance-download"
:entity-name="progressItem.title || item.title"
:entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon"
:status-text="downloadStatusText(progressItem)"
:progress="progressItem.progress"
:waiting="progressItem.waiting"
@dismiss="dismiss(item.id)"
/>
</div>
<div
v-else
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
>
<div class="flex flex-col gap-2 w-full">
@@ -118,6 +149,7 @@ import {
DownloadIcon,
InfoIcon,
IssuesIcon,
MinecraftServerIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
@@ -127,9 +159,11 @@ import {
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationButton,
type PopupNotificationProgressItem,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() =>
@@ -141,6 +175,34 @@ const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
function isDownloadNotification(item: PopupNotification) {
return (
item.type === 'download' &&
(!!item.progressItems?.length || item.progress != null || item.waiting)
)
}
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
if (item.progressItems?.length) {
return item.progressItems
}
return [
{
id: `${item.id}`,
title: item.title,
text: item.text,
iconUrl: item.iconUrl,
progress: item.progress ?? 0,
waiting: item.waiting ?? false,
},
]
}
function downloadStatusText(progressItem: PopupNotificationProgressItem) {
return progressItem.text?.replace(/^\d+%\s*/, '') ?? ''
}
function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
btn.action()
if (!btn.keepOpen) {
@@ -148,6 +210,11 @@ function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
}
}
async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) {
popupNotificationManager.removeNotification(item.id)
await action?.()
}
function progressColorForType(type: PopupNotification['type']) {
if (type === 'error') {
return 'red'
@@ -179,8 +246,9 @@ withDefaults(
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem;
z-index: 200;
width: 520px;
max-width: calc(100vw - 3rem);
width: min(420px, calc(100vw - 1.5rem));
min-width: min(420px, calc(100vw - 1.5rem));
max-width: min(420px, calc(100vw - 1.5rem));
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -192,8 +260,6 @@ withDefaults(
@media screen and (max-width: 500px) {
.popup-notification-group {
width: calc(100% - 1.5rem);
max-width: none;
right: 0.75rem;
}
}
@@ -0,0 +1,61 @@
<template>
<div
class="notification-stack"
:class="{
'has-sidebar': hasSidebar,
}"
>
<TransitionGroup name="notification-stack-item" tag="div" class="notification-stack-items">
<slot />
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
hasSidebar?: boolean
}>(),
{
hasSidebar: false,
},
)
</script>
<style scoped>
.notification-stack {
position: fixed;
top: calc(var(--top-bar-height, 3rem) + 1rem);
right: 1rem;
z-index: 200;
width: min(420px, calc(100vw - 1.5rem));
}
.notification-stack.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1rem);
}
@media screen and (max-width: 500px) {
.notification-stack {
right: 0.75rem;
}
}
.notification-stack-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
:global(.notification-stack-item-enter-active),
:global(.notification-stack-item-leave-active),
:global(.notification-stack-item-move) {
transition: all 0.3s ease-in-out;
}
:global(.notification-stack-item-enter-from),
:global(.notification-stack-item-leave-to) {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
</style>
@@ -0,0 +1,273 @@
<template>
<div
class="notification-toast relative overflow-hidden rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-4 shadow-[0px_4px_8px_0px_rgba(0,0,0,0.1),0px_1px_3px_0px_rgba(0,0,0,0.2)]"
>
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
<Avatar
:src="actorAvatarUrl"
:alt="actorLabel"
:tint-by="actorLabel"
size="44px"
circle
no-shadow
class="border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col gap-2.5">
<div class="flex w-full items-start gap-1">
<p class="m-0 min-w-0 flex-1 break-words text-lg font-normal leading-6 text-contrast/85">
<template v-if="type === 'friend-request'">
<span class="font-semibold text-contrast">{{ actorLabel }}</span>
<span> sent you a friend request.</span>
</template>
<template v-else>
<button
v-if="actorName"
type="button"
class="m-0 inline border-0 bg-transparent p-0 text-lg font-semibold leading-6 text-contrast hover:underline"
@click="$emit('open-actor')"
>
{{ actorName }}
</button>
<span v-else class="font-semibold text-contrast">Someone</span>
<span class="mx-1">{{ inviteActionText }}</span>
<template v-if="type === 'server-invite'">
<span class="font-semibold text-contrast">{{ entityLabel }}</span
>.
</template>
<template v-else>
<span class="inline-flex max-w-full items-center gap-[5px] align-[-4px]">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="24px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-[7px]"
/>
<span class="min-w-0 truncate font-semibold text-contrast">{{
entityLabel
}}</span> </span
>.
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('accept')">Accept</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('decline')">Decline</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="flex w-full items-start gap-3">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="44px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-xl border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col" :class="{ 'gap-2.5': type === 'instance-ready' }">
<div class="flex min-w-0 flex-1 items-start gap-1">
<div class="flex min-w-0 flex-1 flex-col gap-[3px] text-base leading-5">
<p
ref="titleRef"
v-tooltip="truncatedTooltip(titleRef, entityLabel)"
class="m-0 min-w-0 truncate text-lg font-semibold leading-6 text-contrast"
>
{{ entityLabel }}
</p>
<p
ref="statusRef"
v-tooltip="truncatedTooltip(statusRef, statusLine)"
class="m-0 min-w-0 truncate font-normal leading-tight text-contrast/85"
>
{{ statusLine }}
</p>
</div>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div v-if="type === 'instance-ready'" class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('launch')">Launch game</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('open-instance')">Instance</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="showsBottomProgress"
class="notification-bottom-progress-track absolute inset-x-0 bottom-0 h-1 overflow-hidden"
role="progressbar"
:aria-valuenow="isWaitingProgress ? undefined : progressPercent"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full transition-[left,width] duration-200 ease-in-out"
:class="[
type === 'instance-ready' ? 'bg-surface-5' : 'bg-brand',
{ 'notification-bottom-progress--waiting': isWaitingProgress },
]"
:style="isWaitingProgress ? undefined : { width: `${progressPercent}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
type NotificationToastType =
| 'friend-request'
| 'server-invite'
| 'instance-invite'
| 'instance-download'
| 'instance-ready'
const props = withDefaults(
defineProps<{
type: NotificationToastType
actorName?: string | null
actorAvatarUrl?: string | null
entityName?: string
entityIconUrl?: string | null
statusText?: string
progress?: number
waiting?: boolean
}>(),
{
actorName: null,
actorAvatarUrl: null,
entityName: '',
entityIconUrl: null,
waiting: false,
},
)
defineEmits<{
accept: []
decline: []
dismiss: []
launch: []
'open-actor': []
'open-instance': []
}>()
const isInviteNotification = computed(
() =>
props.type === 'friend-request' ||
props.type === 'server-invite' ||
props.type === 'instance-invite',
)
const actorLabel = computed(() => props.actorName || 'Someone')
const entityLabel = computed(() => props.entityName || '')
const progressValue = computed(() => Math.max(0, Math.min(1, props.progress ?? 0)))
const progressPercent = computed(() => Math.round(progressValue.value * 100))
const isWaitingProgress = computed(() => props.type === 'instance-download' && props.waiting)
const inviteActionText = computed(() => {
if (props.type === 'server-invite') {
return 'invited you to manage the server'
}
return 'invited you to play the instance'
})
const resolvedStatusText = computed(() => {
if (props.type === 'instance-ready') {
return props.statusText ?? 'Installed and ready to play.'
}
return props.statusText ?? ''
})
const statusLine = computed(() => {
if (props.type !== 'instance-download' || props.waiting) {
return resolvedStatusText.value
}
const status = resolvedStatusText.value.trim()
return status ? `${status} ${progressPercent.value}%` : `${progressPercent.value}%`
})
const showsBottomProgress = computed(
() =>
props.type === 'instance-download' ||
(props.type === 'instance-ready' && props.progress != null),
)
const titleRef = ref<HTMLElement | null>(null)
const statusRef = ref<HTMLElement | null>(null)
</script>
<style scoped>
.notification-toast {
width: min(420px, calc(100vw - 1.5rem));
}
.notification-toast-dismiss {
--_height: 1.25rem;
--_width: 1.25rem;
--_padding-x: 0;
--_padding-y: 0;
--_icon-size: 1.25rem;
--_box-shadow: none;
--_text: var(--color-base);
--_hover-bg: transparent;
--_hover-text: var(--color-contrast);
}
.notification-bottom-progress--waiting {
animation: notification-bottom-progress-waiting 1s linear infinite;
position: relative;
width: 20%;
}
@keyframes notification-bottom-progress-waiting {
0% {
left: -20%;
}
100% {
left: 100%;
}
}
.notification-bottom-progress-track {
background-color: color-mix(in srgb, var(--surface-2) 50%, transparent);
}
</style>
@@ -0,0 +1,2 @@
export { default as NotificationStack } from './NotificationStack.vue'
export { default as NotificationToast } from './NotificationToast.vue'
@@ -28,7 +28,13 @@
</div>
<template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button class="!border" type="button" @click="emit('retry')">
<button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
class="!border"
type="button"
:disabled="retryDisabled"
@click="emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
@@ -62,6 +68,8 @@ const props = defineProps<{
fallbackPhase?: SyncProgress['phase'] | null
contentError?: ContentError | null
dismissible?: boolean
retryDisabled?: boolean
retryDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -35,10 +35,26 @@
</div>
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
<div class="ml-4 flex flex-col gap-1.5">
<div class="flex flex-row items-center gap-2">
<div class="flex flex-row items-center gap-2.5">
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
{{ name }}
</h2>
<div
v-if="owner"
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
class="flex min-w-0 items-center gap-1 rounded-full bg-surface-4 px-2 pr-2.5 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
:class="{ 'opacity-50': isDisabled }"
>
<Avatar
:src="owner.avatarUrl"
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
:tint-by="owner.username"
size="1.25rem"
circle
no-shadow
/>
<span class="max-w-32 truncate">{{ owner.username }}</span>
</div>
<div
v-if="isConfiguring && noticeType !== 'cancelled' && noticeType !== 'setToCancel'"
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
@@ -262,6 +278,7 @@ import { injectModrinthClient } from '../../providers/api-client'
import Avatar from '../base/Avatar.vue'
import IntlFormatted from '../base/IntlFormatted.vue'
import ServersSpecs from '../billing/ServersSpecs.vue'
import type { ServerListingOwner } from './access/types'
import ServerIcon from './icons/ServerIcon.vue'
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
@@ -281,6 +298,14 @@ const messages = defineMessages({
id: 'servers.listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
ownerTooltip: {
id: 'servers.listing.owner-tooltip',
defaultMessage: 'Owned by {username}',
},
ownerAvatarAlt: {
id: 'servers.listing.owner-avatar-alt',
defaultMessage: "{username}'s avatar",
},
provisioningNotice: {
id: 'servers.listing.notice.provisioning',
defaultMessage: 'Please wait while we set up your server. This can take up to 10 minutes.',
@@ -402,6 +427,7 @@ type ServerListingProps = {
cancellationDate?: string | Date | null
onResubscribe?: (() => void) | null
onDownloadBackup?: (() => void) | null
owner?: ServerListingOwner
}
const props = defineProps<ServerListingProps>()
@@ -60,13 +60,13 @@
<script setup lang="ts">
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
import type { ServerStats } from '#ui/providers/server-context'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
@@ -82,7 +82,7 @@ const { featureFlags } = injectPageContext()
const props = withDefaults(
defineProps<{
data?: Stats
data?: ServerStats
loading?: boolean
showMemoryAsBytes?: boolean
}>(),
@@ -11,6 +11,8 @@
:fade="props.initialSetup ? undefined : 'danger'"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:finish-disabled="!canCompleteSetup"
:finish-disabled-tooltip="!canCompleteSetup ? permissionDeniedMessage : undefined"
@create="onFlowComplete"
@hide="$emit('hide')"
@browse-modpacks="$emit('browse-modpacks')"
@@ -24,6 +26,7 @@ import type { Archon, ModrinthApiError } from '@modrinth/api-client'
import { computed, useTemplateRef } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { useServerPermissions } from '#ui/composables/server-permissions'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers/api-client'
@@ -60,6 +63,7 @@ const serverContext = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const serverLoaders = ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']
const { canSetup, canResetServer, permissionDeniedMessage } = useServerPermissions()
async function searchModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
@@ -97,12 +101,20 @@ const initialLoader = computed(() => {
})
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
const canCompleteSetup = computed(() =>
props.initialSetup ? canSetup.value : canResetServer.value,
)
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
const uploadProgressModal =
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
async function onFlowComplete(ctx: CreationFlowContextValue) {
if (!canCompleteSetup.value) {
ctx.loading.value = false
return
}
debug('onFlowComplete:', {
setupType: ctx.setupType.value,
hasModpackFile: !!ctx.modpackFile.value,
@@ -207,6 +219,8 @@ function emitReinstall(args?: { loader: string; lVersion: string; mVersion: stri
}
function show() {
if (!canCompleteSetup.value) return
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('paper')
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('purpur')
creationFlowRef.value?.show()
@@ -0,0 +1,625 @@
<template>
<Table
v-if="members.length > 0"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
class="hidden sm:block"
:columns="columns"
:data="tableMembers"
row-key="id"
table-min-width="42rem"
>
<template #cell-user="{ row: member }">
<AutoLink
:to="userProfilePath(member.user.username)"
class="inline-flex max-w-full min-w-0 items-center gap-2"
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="member.user.avatarUrl"
:alt="formatMessage(messages.userAvatarAlt, { username: member.user.username })"
:tint-by="member.user.username"
size="22px"
circle
no-shadow
/>
<span class="min-w-0 truncate font-medium">
{{ member.user.username }}
</span>
</AutoLink>
</template>
<template #cell-role="{ row: member }">
<span
v-if="member.isOwner"
class="inline-flex h-7 items-center rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none"
:class="roleClasses(member.role)"
>
{{ formatRole(member.role) }}
</span>
<div v-else v-tooltip="accessManagementTooltip" class="w-fit">
<Combobox
:model-value="member.role"
:options="roleComboboxOptions"
:display-value="formatRole(member.role)"
:disabled="!canManageUsers"
:trigger-class="`${roleTriggerClass(member.role)} !inline-flex !w-auto !h-7 !min-h-0 !rounded-full !border !border-solid !px-2.5 !py-1 gap-1 !text-sm !font-semibold !leading-5`"
dropdown-class="!rounded-[24px] !bg-surface-3"
dropdown-min-width="18rem"
force-direction="down"
@update:model-value="(role) => handleUpdateRole(member, role)"
>
<template #selected>
<span class="font-semibold leading-5" :class="roleTextClass(member.role)">
{{ formatRole(member.role) }}
</span>
</template>
</Combobox>
</div>
</template>
<template #cell-joined="{ row: member }">
<span
v-if="member.pending"
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
{{ formatMessage(messages.pendingLabel) }}
</span>
<span v-else-if="member.joinedAt" v-tooltip="formatDate(member.joinedAt)">
{{ formatRelativeTime(member.joinedAt) }}
</span>
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
</template>
<template #cell-actions="{ row: member }">
<div v-if="!member.isOwner" class="flex items-center justify-end gap-1">
<ButtonStyled v-if="member.pending" circular type="transparent">
<button
v-tooltip="resendInviteTooltip(member)"
:aria-label="resendInviteLabel(member)"
:disabled="resendInviteDisabled(member)"
class="text-secondary hover:!filter-none hover:text-contrast focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="handleResendInvite(member)"
>
<SendIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button
v-tooltip="memberAccessActionTooltip(member)"
:aria-label="memberAccessActionLabel(member)"
:disabled="!canManageUsers"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="member.pending ? handleCancelInvite(member) : handleRemoveMember(member)"
>
<XIcon v-if="member.pending" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
<div
v-if="members.length > 0"
class="overflow-hidden rounded-2xl border border-solid border-surface-5 sm:hidden"
>
<div
class="grid min-h-14 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] bg-surface-3"
>
<div class="flex items-center pl-4 font-semibold text-secondary">
<button
type="button"
class="flex min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
:class="sortColumn === 'user' ? 'text-contrast' : 'text-secondary'"
@click="toggleSort('user')"
>
<span class="min-w-0 truncate">{{ formatMessage(messages.userColumn) }}</span>
<component :is="sortIcon('user')" v-if="sortIcon('user')" class="size-4" />
</button>
</div>
<div class="flex items-center font-semibold text-secondary">
<button
type="button"
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
:class="sortColumn === 'role' ? 'text-contrast' : 'text-secondary'"
@click="toggleSort('role')"
>
{{ formatMessage(messages.roleColumn) }}
<component :is="sortIcon('role')" v-if="sortIcon('role')" class="size-4" />
</button>
</div>
<div class="flex items-center justify-end font-semibold text-secondary">
<button
type="button"
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
:class="sortColumn === 'joined' ? 'text-contrast' : 'text-secondary'"
@click="toggleSort('joined')"
>
{{ formatMessage(messages.joinedColumn) }}
<component :is="sortIcon('joined')" v-if="sortIcon('joined')" class="size-4" />
</button>
</div>
<div class="flex items-center justify-end pr-4 font-semibold text-secondary">
<span class="sr-only">{{ formatMessage(messages.actionsColumn) }}</span>
</div>
</div>
<div
v-for="(member, index) in sortedMembers"
:key="member.id"
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-5"
:class="index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<div class="flex min-w-0 items-center pl-4">
<AutoLink
v-tooltip="member.user.username"
:to="userProfilePath(member.user.username)"
class="inline-flex min-w-0 items-center gap-2"
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="member.user.avatarUrl"
:alt="formatMessage(messages.userAvatarAlt, { username: member.user.username })"
:tint-by="member.user.username"
size="24px"
circle
no-shadow
/>
<span class="min-w-0 truncate font-medium">
{{ member.user.username }}
</span>
</AutoLink>
</div>
<div class="min-w-0 py-3 pr-2">
<span
v-if="member.isOwner"
class="inline-flex h-7 max-w-full items-center truncate rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none"
:class="roleClasses(member.role)"
>
{{ formatRole(member.role) }}
</span>
<div v-else v-tooltip="accessManagementTooltip" class="min-w-0">
<Combobox
:model-value="member.role"
:options="roleComboboxOptions"
:display-value="formatRole(member.role)"
:disabled="!canManageUsers"
:trigger-class="
roleTriggerClass(member.role) +
` !inline-flex !w-auto !max-w-full !h-7 !min-h-0 !rounded-full !border !border-solid !px-2.5 !py-1 gap-1 !text-sm !font-semibold !leading-5`
"
dropdown-class="!rounded-[24px] !bg-surface-3"
dropdown-min-width="18rem"
force-direction="down"
@update:model-value="(role) => handleUpdateRole(member, role)"
>
<template #selected>
<span
class="min-w-0 truncate font-semibold leading-5"
:class="roleTextClass(member.role)"
>
{{ formatRole(member.role) }}
</span>
</template>
</Combobox>
</div>
</div>
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
<span
v-if="member.pending"
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
{{ formatMessage(messages.pendingLabel) }}
</span>
<span
v-else-if="member.joinedAt"
v-tooltip="formatDate(member.joinedAt)"
class="inline-block max-w-full truncate"
>
{{ formatRelativeTime(member.joinedAt) }}
</span>
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
</div>
<div class="flex min-w-0 items-center justify-end pr-4">
<ButtonStyled v-if="!member.isOwner" circular type="transparent">
<TeleportOverflowMenu
:options="memberActionOptions(member)"
btn-class="hover:!filter-none focus-visible:!filter-none active:!scale-100 active:!filter-none"
>
<MoreVerticalIcon aria-hidden="true" class="size-5" />
<span class="sr-only">
{{ formatMessage(messages.memberActionsLabel, { username: member.user.username }) }}
</span>
<template #resend-invite>
<SendIcon aria-hidden="true" />
{{ resendInviteLabel(member) }}
</template>
<template #cancel-invite>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelInvite) }}
</template>
<template #remove-user>
<UserXIcon aria-hidden="true" />
{{ formatMessage(messages.removeUser) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div
class="grid min-h-14 grid-cols-[3.75rem_7.25rem_minmax(0,1fr)_2.75rem] bg-surface-3 sm:h-14 sm:grid-cols-[32%_28%_28%_12%]"
>
<div class="flex items-center pl-4 font-semibold text-secondary">
{{ formatMessage(messages.userColumn) }}
</div>
<div class="flex items-center font-semibold text-secondary">
{{ formatMessage(messages.roleColumn) }}
</div>
<div class="flex items-center font-semibold text-secondary">
{{ formatMessage(messages.joinedColumn) }}
</div>
<div class="flex items-center justify-end pr-4 font-semibold text-secondary">
{{ formatMessage(messages.actionsColumn) }}
</div>
</div>
<div
class="border-0 border-t border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary"
>
{{ formatMessage(messages.emptyState) }}
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
ChevronUpIcon,
MoreVerticalIcon,
SendIcon,
UserXIcon,
XIcon,
} from '@modrinth/assets'
import { type Component, computed, onMounted, onUnmounted, ref } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.vue'
import type { ServerAccessMember, ServerAccessRole, ServerAccessRoleOption } from './types'
const props = withDefaults(
defineProps<{
members: ServerAccessMember[]
roles: ServerAccessRoleOption[]
canManageUsers?: boolean
permissionDeniedMessage?: string
}>(),
{
canManageUsers: true,
},
)
const emit = defineEmits<{
updateRole: [member: ServerAccessMember, role: ServerAccessRole]
resendInvite: [member: ServerAccessMember]
cancelInvite: [member: ServerAccessMember]
removeMember: [member: ServerAccessMember]
}>()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const messages = defineMessages({
userColumn: {
id: 'servers.access-table.column.user',
defaultMessage: 'User',
},
roleColumn: {
id: 'servers.access-table.column.role',
defaultMessage: 'Role',
},
joinedColumn: {
id: 'servers.access-table.column.joined',
defaultMessage: 'Joined',
},
actionsColumn: {
id: 'servers.access-table.column.actions',
defaultMessage: 'Actions',
},
memberActionsLabel: {
id: 'servers.access-table.member-actions-label',
defaultMessage: 'Actions for {username}',
},
pendingLabel: {
id: 'servers.access-table.pending',
defaultMessage: 'Pending',
},
unknownJoinedDate: {
id: 'servers.access-table.unknown-joined-date',
defaultMessage: '—',
},
resendInvite: {
id: 'servers.access-table.action.resend-invite',
defaultMessage: 'Resend invite',
},
cancelInvite: {
id: 'servers.access-table.action.cancel-invite',
defaultMessage: 'Cancel invite',
},
removeUser: {
id: 'servers.access-table.action.remove-user',
defaultMessage: 'Remove user',
},
emptyState: {
id: 'servers.access-table.empty',
defaultMessage: 'No users match your filters.',
},
userAvatarAlt: {
id: 'servers.access-table.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
ownerRole: {
id: 'servers.access-role.owner',
defaultMessage: 'Owner',
},
editorRole: {
id: 'servers.access-role.editor',
defaultMessage: 'Editor',
},
viewerRole: {
id: 'servers.access-role.viewer',
defaultMessage: 'Limited',
},
resendInviteCooldown: {
id: 'servers.access-table.action.resend-invite-cooldown',
defaultMessage: 'Resend in {seconds}s',
},
})
type AccessTableColumn = 'user' | 'role' | 'joined' | 'actions'
type AccessTableSortableColumn = Exclude<AccessTableColumn, 'actions'>
type AccessTableRow = ServerAccessMember & Record<string, unknown>
type OverflowMenuOption = {
id: string
icon?: Component
action: () => void
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
disabled?: boolean
tooltip?: string
}
const columns = computed<TableColumn<AccessTableColumn>[]>(() => [
{ key: 'user', label: formatMessage(messages.userColumn), width: '32%', enableSorting: true },
{ key: 'role', label: formatMessage(messages.roleColumn), width: '28%', enableSorting: true },
{ key: 'joined', label: formatMessage(messages.joinedColumn), enableSorting: true },
{ key: 'actions', label: formatMessage(messages.actionsColumn), align: 'right', width: '7rem' },
])
const sortColumn = ref<string | undefined>('role')
const sortDirection = ref<SortDirection>('asc')
const now = ref(Date.now())
let nowInterval: ReturnType<typeof setInterval> | null = null
const canManageUsers = computed(() => props.canManageUsers)
const permissionDeniedMessage = computed(
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
)
const accessManagementTooltip = computed(() =>
canManageUsers.value ? undefined : permissionDeniedMessage.value,
)
const roleSortOrder: Record<ServerAccessRole, number> = {
owner: 0,
editor: 1,
viewer: 2,
}
const sortedMembers = computed(() => {
const direction = sortDirection.value === 'asc' ? 1 : -1
const column = normalizeSortColumn(sortColumn.value)
return [...props.members].sort((a, b) => {
const compared = compareMembers(a, b, column)
if (compared !== 0) return compared * direction
return a.user.username.localeCompare(b.user.username)
})
})
const tableMembers = computed<AccessTableRow[]>(() => sortedMembers.value as AccessTableRow[])
onMounted(() => {
nowInterval = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (nowInterval) clearInterval(nowInterval)
})
function formatRole(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return formatMessage(messages.ownerRole)
case 'editor':
return formatMessage(messages.editorRole)
case 'viewer':
return formatMessage(messages.viewerRole)
}
}
const roleComboboxOptions = computed<ComboboxOption<ServerAccessRole>[]>(() =>
props.roles
.filter((role) => role.value !== 'owner')
.map((role) => ({
value: role.value,
label: role.label,
subLabel: role.description,
})),
)
function compareMembers(
a: ServerAccessMember,
b: ServerAccessMember,
column: AccessTableSortableColumn,
): number {
switch (column) {
case 'user':
return a.user.username.localeCompare(b.user.username)
case 'role':
return roleSortOrder[a.role] - roleSortOrder[b.role]
case 'joined':
return joinedTimestamp(a) - joinedTimestamp(b)
}
}
function normalizeSortColumn(column: string | undefined): AccessTableSortableColumn {
return column === 'user' || column === 'role' || column === 'joined' ? column : 'joined'
}
function joinedTimestamp(member: ServerAccessMember): number {
if (member.pending) return Number.NEGATIVE_INFINITY
return member.joinedAt ? new Date(member.joinedAt).getTime() : 0
}
function toggleSort(column: AccessTableSortableColumn) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
return
}
sortColumn.value = column
sortDirection.value = 'asc'
}
function sortIcon(column: AccessTableSortableColumn): Component | null {
if (sortColumn.value !== column) return null
return sortDirection.value === 'asc' ? ChevronUpIcon : ChevronDownIcon
}
function roleClasses(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return 'border-orange !bg-highlight-orange !text-orange'
case 'editor':
return 'border-green !bg-highlight-green !text-brand'
case 'viewer':
return 'border-blue !bg-highlight-blue !text-blue'
}
}
function roleTextClass(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return '!text-orange'
case 'editor':
return '!text-green'
case 'viewer':
return '!text-blue'
}
}
function roleTriggerClass(role: ServerAccessRole): string {
return roleClasses(role)
}
function userProfilePath(username: string): string | undefined {
if (!username || username.includes('@')) return undefined
return `/user/${encodeURIComponent(username)}`
}
function resendInviteCooldownSeconds(member: ServerAccessMember): number {
const availableAt = member.inviteResendAvailableAt
? new Date(member.inviteResendAvailableAt).getTime()
: 0
return Math.max(0, Math.ceil((availableAt - now.value) / 1000))
}
function resendInviteCooldownDisabled(member: ServerAccessMember): boolean {
return resendInviteCooldownSeconds(member) > 0
}
function resendInviteDisabled(member: ServerAccessMember): boolean {
return !canManageUsers.value || resendInviteCooldownDisabled(member)
}
function resendInviteLabel(member: ServerAccessMember): string {
const seconds = resendInviteCooldownSeconds(member)
return seconds > 0
? formatMessage(messages.resendInviteCooldown, { seconds })
: formatMessage(messages.resendInvite)
}
function resendInviteTooltip(member: ServerAccessMember): string {
return canManageUsers.value ? resendInviteLabel(member) : permissionDeniedMessage.value
}
function handleResendInvite(member: ServerAccessMember) {
if (resendInviteDisabled(member)) return
emit('resendInvite', member)
}
function memberAccessActionLabel(member: ServerAccessMember): string {
return member.pending ? formatMessage(messages.cancelInvite) : formatMessage(messages.removeUser)
}
function memberAccessActionTooltip(member: ServerAccessMember): string {
return canManageUsers.value ? memberAccessActionLabel(member) : permissionDeniedMessage.value
}
function handleUpdateRole(member: ServerAccessMember, role: ServerAccessRole) {
if (!canManageUsers.value) return
emit('updateRole', member, role)
}
function handleCancelInvite(member: ServerAccessMember) {
if (!canManageUsers.value) return
emit('cancelInvite', member)
}
function handleRemoveMember(member: ServerAccessMember) {
if (!canManageUsers.value) return
emit('removeMember', member)
}
function memberActionOptions(member: ServerAccessMember): OverflowMenuOption[] {
return [
{
id: 'resend-invite',
icon: SendIcon,
action: () => handleResendInvite(member),
shown: member.pending,
disabled: resendInviteDisabled(member),
tooltip: resendInviteTooltip(member),
},
{
id: 'cancel-invite',
icon: XIcon,
action: () => handleCancelInvite(member),
color: 'red',
shown: member.pending,
disabled: !canManageUsers.value,
tooltip: memberAccessActionTooltip(member),
},
{
id: 'remove-user',
icon: UserXIcon,
action: () => handleRemoveMember(member),
color: 'red',
shown: !member.pending,
disabled: !canManageUsers.value,
tooltip: memberAccessActionTooltip(member),
},
]
}
</script>
@@ -0,0 +1,13 @@
<template>
<div class="audit-log-table-event min-w-0 truncate">
<component :is="event.component" v-bind="event.props" class="audit-log-table-event-component" />
</div>
</template>
<script setup lang="ts">
import type { ParsedAuditEvent } from './events/types'
defineProps<{
event: ParsedAuditEvent
}>()
</script>
@@ -0,0 +1,706 @@
<template>
<div class="@container flex flex-col gap-4">
<div class="flex min-w-0 flex-col items-start gap-3 @[640px]:flex-row @[640px]:items-center">
<TimeFramePicker
v-model:mode="timeframeMode"
v-model:preset="timeframePreset"
v-model:last-amount="timeframeLastAmount"
v-model:last-unit="timeframeLastUnit"
v-model:custom-start-date="timeframeCustomStartDate"
v-model:custom-end-date="timeframeCustomEndDate"
:class="timeframePickerClass"
:trigger-class="timeframePickerTriggerClass"
/>
<template v-if="slots.filters">
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-5 @[640px]:ml-1 @[640px]:block"></div>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<slot name="filters"></slot>
</div>
</template>
</div>
<div class="audit-log-content-frame relative overflow-hidden" :style="contentFrameStyle">
<div ref="contentBody" class="min-w-0">
<Table
v-if="filteredEntries.length > 0"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
class="audit-log-table hidden @[800px]:block"
:columns="columns"
:data="tableEntries"
row-key="id"
:row-transition-name="rowTransitionName"
>
<template #header-world="{ column }">
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
<span class="min-w-0 truncate">{{ column.label }}</span>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
popper-class="v-popper--interactive"
placement="top"
:delay="{ show: 200, hide: 100 }"
no-auto-focus
>
<button
type="button"
:aria-label="formatMessage(messages.instanceTooltipTitle)"
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
>
<UnknownIcon class="size-4" aria-hidden="true" />
</button>
<template #popper>
<div class="grid !w-64 gap-1">
<h3 class="m-0 whitespace-nowrap text-base w-full font-bold text-contrast">
{{ formatMessage(messages.instanceTooltipTitle) }}
</h3>
<p
class="m-0 text-wrap text-sm w-full font-medium leading-tight text-secondary"
>
{{ formatMessage(messages.instanceTooltipDescription) }}
</p>
</div>
</template>
</Tooltip>
</span>
</template>
<template #cell-user="{ row: entry }">
<AutoLink
v-tooltip="actorName(entry)"
:to="actorProfilePath(entry)"
class="flex min-w-0 items-center gap-2 whitespace-nowrap"
:class="actorProfilePath(entry) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="
entry.actor.id === 'support'
? IntercomBubbleIcon
: (entry.actor.avatarUrl ?? undefined)
"
:alt="formatMessage(messages.userAvatarAlt, { username: actorName(entry) })"
:tint-by="entry.actor.username"
size="22px"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-medium"
:class="entry.actor.id === 'support' ? 'text-blue' : ''"
>
{{ actorName(entry) }}
</span>
</AutoLink>
</template>
<template #cell-event="{ row: entry }">
<AuditLogEventCell :event="entry.event" />
</template>
<template #cell-world="{ row: entry }">
<span
v-tooltip="entry.world?.name"
class="block min-w-0 truncate whitespace-nowrap"
:class="entry.world ? 'text-primary' : 'text-secondary'"
>
{{ entry.world?.name ?? '—' }}
</span>
</template>
<template #cell-time="{ row: entry }">
<span
v-tooltip="formatDate(entry.timestamp)"
class="inline-block whitespace-nowrap align-middle leading-6"
>
{{ formatRelativeTime(entry.timestamp) }}
</span>
</template>
</Table>
<TransitionGroup
v-if="filteredEntries.length > 0"
name="audit-log-card"
tag="div"
class="flex flex-col gap-3 @[800px]:hidden"
>
<div
v-for="entry in filteredEntries"
:key="entry.id"
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<AutoLink
v-tooltip="actorName(entry)"
:to="actorProfilePath(entry)"
class="inline-flex min-w-0 items-center gap-2 self-start"
:class="actorProfilePath(entry) ? 'text-primary hover:underline' : 'text-primary'"
>
<Avatar
:src="actorAvatarSrc(entry)"
:alt="formatMessage(messages.userAvatarAlt, { username: actorName(entry) })"
:tint-by="entry.actor.username"
size="24px"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-medium"
:class="entry.actor.id === 'support' ? 'text-blue' : ''"
>
{{ actorName(entry) }}
</span>
</AutoLink>
<div class="min-w-0">
<component :is="entry.event.component" v-bind="entry.event.props" />
</div>
<div class="flex min-w-0 items-center gap-1 text-sm text-secondary">
<span v-if="showWorldColumn" v-tooltip="entry.world?.name" class="min-w-0 truncate">
{{ entry.world?.name ?? formatMessage(messages.serverScope) }}
</span>
<BulletDivider v-if="showWorldColumn" class="shrink-0" />
<span v-tooltip="formatDate(entry.timestamp)" class="shrink-0">
{{ formatRelativeTime(entry.timestamp) }}
</span>
</div>
</div>
</TransitionGroup>
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
:class="
showWorldColumn
? '@[800px]:grid-cols-[18%_52%_20%_10%]'
: '@[800px]:grid-cols-[26%_58%_16%]'
"
>
<div class="hidden items-center pl-4 pr-2 font-semibold text-secondary @[800px]:flex">
{{ formatMessage(messages.userColumn) }}
</div>
<div class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex">
{{ formatMessage(messages.eventColumn) }}
</div>
<div
v-if="showWorldColumn"
class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex"
>
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
<span class="min-w-0 truncate">{{ formatMessage(messages.worldColumn) }}</span>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
popper-class="v-popper--interactive"
placement="top"
:delay="{ show: 200, hide: 100 }"
no-auto-focus
>
<button
type="button"
:aria-label="formatMessage(messages.instanceTooltipTitle)"
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
>
<UnknownIcon class="size-4" aria-hidden="true" />
</button>
<template #popper>
<div class="grid !w-64 gap-1">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.instanceTooltipTitle) }}
</h3>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.instanceTooltipDescription) }}
</p>
</div>
</template>
</Tooltip>
</span>
</div>
<div
class="hidden items-center justify-end pl-2 pr-4 font-semibold text-secondary @[800px]:flex"
>
{{ formatMessage(messages.timeColumn) }}
</div>
</div>
<div
class="border-0 border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
>
{{ formatMessage(emptyStateMessage) }}
</div>
</div>
</div>
<Transition name="audit-log-loading-fade">
<div
v-if="loading"
class="pointer-events-none absolute bottom-px left-px right-px top-0 z-20 animate-audit-log-bpulse rounded-[15px] bg-surface-3 @[800px]:top-[57px] @[800px]:rounded-t-none"
aria-hidden="true"
/>
</Transition>
</div>
<div
v-if="loadingMore"
class="h-8 animate-audit-log-bpulse rounded-xl bg-surface-3"
aria-hidden="true"
></div>
<div v-if="hasMore" ref="loadMoreSentinel" class="h-px"></div>
</div>
</template>
<script setup lang="ts">
import { IntercomBubbleIcon, UnknownIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import BulletDivider from '../../base/BulletDivider.vue'
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
import TimeFramePicker, {
type TimeFrameLastUnit,
type TimeFrameMode,
type TimeFramePreset,
} from '../../base/TimeFramePicker.vue'
import AuditLogEventCell from './AuditLogEventCell.vue'
import type { ServerAuditLogEntry, ServerAuditLogFilters } from './types'
const props = defineProps<{
entries: ServerAuditLogEntry[]
hasActiveExternalFilters?: boolean
hasMore?: boolean
loading?: boolean
loadingMore?: boolean
showWorldColumn?: boolean
suppressRowTransitions?: boolean
}>()
const emit = defineEmits<{
'load-more': []
}>()
const query = defineModel<string>('query', { default: '' })
const timeframeMode = defineModel<TimeFrameMode>('timeframeMode', { default: 'preset' })
const timeframePreset = defineModel<TimeFramePreset>('timeframePreset', { default: 'all_time' })
const timeframeLastAmount = defineModel<number>('timeframeLastAmount', { default: 30 })
const timeframeLastUnit = defineModel<TimeFrameLastUnit>('timeframeLastUnit', { default: 'days' })
const timeframeCustomStartDate = defineModel<string>('timeframeCustomStartDate', { default: '' })
const timeframeCustomEndDate = defineModel<string>('timeframeCustomEndDate', { default: '' })
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'desc' })
const filters = defineModel<ServerAuditLogFilters>('filters', {
default: () => ({
userId: null,
worldId: null,
}),
})
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const slots = useSlots()
const sortColumn = ref<string | undefined>('time')
const suppressSortRowTransitions = ref(false)
const loadMoreSentinel = ref<HTMLElement | null>(null)
const contentBody = ref<HTMLElement | null>(null)
const contentHeight = ref<number | null>(null)
const showWorldColumn = computed(() => props.showWorldColumn === true)
let loadMoreObserver: IntersectionObserver | null = null
let contentResizeObserver: ResizeObserver | null = null
let sortTransitionResetTimeout: ReturnType<typeof setTimeout> | null = null
const messages = defineMessages({
supportActor: {
id: 'servers.audit-log.actor.support',
defaultMessage: 'Support',
},
userColumn: {
id: 'servers.audit-log.column.user',
defaultMessage: 'User',
},
worldColumn: {
id: 'servers.audit-log.column.world',
defaultMessage: 'Instance',
},
instanceTooltipTitle: {
id: 'servers.audit-log.column.world.tooltip-title',
defaultMessage: 'Coming soon!',
},
instanceTooltipDescription: {
id: 'servers.audit-log.column.world.tooltip-description',
defaultMessage:
'Server instances are contained environments with their own installed content and world files.',
},
eventColumn: {
id: 'servers.audit-log.column.event',
defaultMessage: 'Actions',
},
timeColumn: {
id: 'servers.audit-log.column.time',
defaultMessage: 'Time',
},
emptyState: {
id: 'servers.audit-log.empty',
defaultMessage: 'No activity matches your filters.',
},
noActivityEmptyState: {
id: 'servers.audit-log.empty.no-activity',
defaultMessage: 'Perform an action on your server and you will see it here!',
},
userAvatarAlt: {
id: 'servers.audit-log.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
serverScope: {
id: 'servers.audit-log.scope.server',
defaultMessage: 'Server',
},
})
const timeframePickerClass = computed(() =>
slots.filters ? '!w-full @[640px]:!w-[225px] shrink-0' : '!w-full @[640px]:!w-[225px]',
)
const timeframePickerTriggerClass =
'!h-10 !min-h-10 !w-full !rounded-[14px] !bg-surface-4 !py-2.5 !pl-4 !pr-3 !text-base shadow-[0px_1px_1px_rgba(0,0,0,0.3),0px_1px_1.5px_rgba(0,0,0,0.15)]'
onMounted(() => {
updateLoadMoreObserver()
updateContentHeightObserver()
})
onBeforeUnmount(() => {
loadMoreObserver?.disconnect()
contentResizeObserver?.disconnect()
if (sortTransitionResetTimeout) {
clearTimeout(sortTransitionResetTimeout)
}
})
watch(
() => [props.hasMore, props.loadingMore, loadMoreSentinel.value] as const,
() => updateLoadMoreObserver(),
{ flush: 'post' },
)
watch(
sortDirection,
(_direction, previousDirection) => {
if (previousDirection === undefined) return
suppressSortRowTransitions.value = true
scheduleSortTransitionReset(1500)
},
{ flush: 'sync' },
)
watch(
() => props.entries,
() => {
if (suppressSortRowTransitions.value) {
scheduleSortTransitionReset(120)
}
},
{ flush: 'post' },
)
type AuditLogTableColumn = 'user' | 'event' | 'world' | 'time'
type AuditLogTableRow = ServerAuditLogEntry & Record<string, unknown>
const columns = computed<TableColumn<AuditLogTableColumn>[]>(() => {
const tableColumns: TableColumn<AuditLogTableColumn>[] = [
{
key: 'user',
label: formatMessage(messages.userColumn),
width: showWorldColumn.value ? '18%' : '26%',
},
{
key: 'event',
label: formatMessage(messages.eventColumn),
width: showWorldColumn.value ? '52%' : '58%',
},
]
if (showWorldColumn.value) {
tableColumns.push({
key: 'world',
label: formatMessage(messages.worldColumn),
width: '20%',
})
}
tableColumns.push({
key: 'time',
label: formatMessage(messages.timeColumn),
align: 'right',
enableSorting: true,
width: showWorldColumn.value ? '10%' : '16%',
})
return tableColumns
})
const rowTransitionName = computed(() =>
props.suppressRowTransitions || suppressSortRowTransitions.value ? undefined : 'audit-log-row',
)
const filteredEntries = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
return props.entries
.filter((entry) => {
if (filters.value.userId && entry.actor.id !== filters.value.userId) return false
if (
showWorldColumn.value &&
filters.value.worldId &&
entry.world?.id !== filters.value.worldId
) {
return false
}
if (!normalizedQuery) return true
return [
entry.actor.username,
showWorldColumn.value ? entry.world?.name : undefined,
entry.event.searchText,
entry.event.key,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(normalizedQuery))
})
.slice()
.sort((a, b) => {
const leftTimestamp = new Date(a.timestamp).getTime()
const rightTimestamp = new Date(b.timestamp).getTime()
return sortDirection.value === 'asc'
? leftTimestamp - rightTimestamp
: rightTimestamp - leftTimestamp
})
})
const tableEntries = computed<AuditLogTableRow[]>(() => filteredEntries.value as AuditLogTableRow[])
const contentFrameStyle = computed(() =>
contentHeight.value === null ? undefined : { height: `${contentHeight.value}px` },
)
watch(
() => [filteredEntries.value.length, props.loading] as const,
() => updateContentHeight(),
{ flush: 'post' },
)
const hasActiveTimeframeFilter = computed(() => {
if (timeframeMode.value === 'preset') {
return timeframePreset.value !== 'all_time'
}
if (timeframeMode.value === 'last') {
return true
}
return Boolean(timeframeCustomStartDate.value || timeframeCustomEndDate.value)
})
const hasActiveFilters = computed(
() =>
props.hasActiveExternalFilters ||
query.value.trim().length > 0 ||
hasActiveTimeframeFilter.value ||
!!filters.value.userId ||
(showWorldColumn.value && !!filters.value.worldId),
)
const emptyStateMessage = computed(() =>
props.entries.length === 0 && !hasActiveFilters.value
? messages.noActivityEmptyState
: messages.emptyState,
)
function actorName(entry: ServerAuditLogEntry): string {
if (entry.actor.id !== 'support') return entry.actor.username
return entry.actor.username === 'support'
? formatMessage(messages.supportActor)
: entry.actor.username
}
function actorAvatarSrc(entry: ServerAuditLogEntry): string | undefined {
return entry.actor.id === 'support' ? IntercomBubbleIcon : (entry.actor.avatarUrl ?? undefined)
}
function actorProfilePath(entry: ServerAuditLogEntry): string | undefined {
return entry.actor.profilePath
}
function updateLoadMoreObserver() {
loadMoreObserver?.disconnect()
loadMoreObserver = null
if (!props.hasMore || typeof IntersectionObserver === 'undefined') {
return
}
nextTick(() => {
if (!loadMoreSentinel.value || !props.hasMore) {
return
}
loadMoreObserver?.disconnect()
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting) && !props.loadingMore) {
emit('load-more')
}
},
{ rootMargin: '400px 0px' },
)
loadMoreObserver.observe(loadMoreSentinel.value)
})
}
function updateContentHeightObserver() {
contentResizeObserver?.disconnect()
contentResizeObserver = null
nextTick(() => {
updateContentHeight()
if (!contentBody.value || typeof ResizeObserver === 'undefined') {
return
}
contentResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.contentRect.height ?? contentBody.value?.getBoundingClientRect().height ?? 0
setContentHeight(height)
})
contentResizeObserver.observe(contentBody.value)
})
}
function updateContentHeight() {
nextTick(() => {
if (!contentBody.value) return
setContentHeight(contentBody.value.getBoundingClientRect().height)
})
}
function setContentHeight(height: number) {
contentHeight.value = Math.ceil(height)
}
function scheduleSortTransitionReset(delay: number) {
if (sortTransitionResetTimeout) {
clearTimeout(sortTransitionResetTimeout)
}
sortTransitionResetTimeout = setTimeout(() => {
suppressSortRowTransitions.value = false
sortTransitionResetTimeout = null
}, delay)
}
</script>
<style>
@media (prefers-reduced-motion: no-preference) {
.audit-log-content-frame {
transition: height 220ms ease-in-out;
}
}
@keyframes audit-log-bpulse {
50% {
filter: brightness(75%);
}
}
.animate-audit-log-bpulse {
animation: audit-log-bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.audit-log-table :is(th, td) {
height: 54px;
padding-left: 0.5rem;
padding-right: 0.5rem;
vertical-align: middle;
}
.audit-log-table :is(th, td):first-child {
padding-left: 1rem;
}
.audit-log-table :is(th, td):last-child {
padding-right: 1rem;
}
.audit-log-table tbody tr {
height: 54px;
max-height: 54px;
}
.audit-log-table tbody td {
height: 54px;
max-height: 54px;
padding-bottom: 0;
padding-top: 0;
}
.audit-log-table-event {
line-height: 1.375rem;
}
.audit-log-table-event-component > span {
line-height: inherit;
}
@container (min-width: 1040px) {
.audit-log-table :is(th, td) {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.audit-log-table :is(th, td):first-child {
padding-left: 1rem;
}
.audit-log-table :is(th, td):last-child {
padding-right: 1rem;
}
.audit-log-table-event {
line-height: 1.375rem;
}
}
.audit-log-loading-fade-enter-active,
.audit-log-loading-fade-leave-active {
transition: opacity 250ms ease-in-out;
}
.audit-log-loading-fade-enter-from,
.audit-log-loading-fade-leave-to {
opacity: 0;
}
.audit-log-row-enter-active,
.audit-log-row-leave-active,
.audit-log-row-move,
.audit-log-card-enter-active,
.audit-log-card-leave-active,
.audit-log-card-move {
transition:
opacity 180ms ease-in-out,
transform 180ms ease-in-out;
}
.audit-log-row-enter-from,
.audit-log-card-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.audit-log-row-leave-to,
.audit-log-card-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
@@ -0,0 +1,459 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
width="min(34rem, calc(100vw - 2rem))"
max-width="34rem"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast" for="grant-access-target">
{{ formatMessage(messages.targetLabel) }}
</label>
<Combobox
id="grant-access-target"
:model-value="undefined"
:options="suggestionOptions"
:search-placeholder="formatMessage(messages.targetPlaceholder)"
:placeholder="formatMessage(messages.targetPlaceholder)"
:no-options-message="targetLookupMessage"
:min-search-length-to-open="suggestionMinimumLength"
:disable-search-filter="usesRemoteLookup"
searchable
show-search-icon
:show-chevron="false"
search-autocomplete="off"
search-autocorrect="off"
search-autocapitalize="none"
:search-spellcheck="false"
@open="targetComboboxOpen = true"
@close="targetComboboxOpen = false"
@search-input="handleTargetSearch"
@select="handleTargetSelect"
>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2">
<Avatar
:src="findSuggestion(item.value)?.avatarUrl"
:alt="formatMessage(messages.suggestionAvatarAlt, { username: item.label })"
:tint-by="item.label"
size="1.5rem"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-semibold"
:class="isSelected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
<span class="m-0 text-base text-primary">
{{ formatMessage(messages.targetHelp) }}
</span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.roleLabel) }}
</span>
<div class="flex flex-col gap-3">
<button
v-for="role in grantableRoles"
:key="role.value"
type="button"
class="group flex w-full items-center gap-3 rounded-[20px] border border-solid p-3 text-left transition-all hover:brightness-110 active:scale-[0.98]"
:class="
selectedRole === role.value
? 'border-brand bg-brand-highlight'
: 'border-transparent bg-surface-4'
"
@click="selectedRole = role.value"
>
<span
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border border-solid"
:class="
selectedRole === role.value
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 text-secondary'
"
>
<component :is="role.icon" class="size-8" stroke-width="1.5" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-contrast">{{ role.label }}</span>
<span class="text-sm font-medium text-primary">{{ role.description }}</span>
</span>
</button>
</div>
<p class="m-0 text-base text-primary">
<IntlFormatted :message-id="messages.permissionsHelp">
<template #link="{ children }">
<a
class="font-medium text-blue hover:underline"
href="/news/article/server-access/"
target="_blank"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<Checkbox
v-if="showAddAsFriend"
v-model="addAsFriend"
:label="formatMessage(messages.addAsFriend)"
label-class="text-base text-contrast"
class="mt-2"
/>
</Transition>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="grantPermissionTooltip" :disabled="!canSubmit" @click="submit">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.inviteButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { EyeIcon, PencilIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Checkbox from '../../base/Checkbox.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
import NewModal from '../../modal/NewModal.vue'
import type {
GrantServerAccessPayload,
ServerAccessInviteSuggestion,
ServerAccessMember,
ServerAccessRole,
} from './types'
const props = withDefaults(
defineProps<{
members?: ServerAccessMember[]
suggestions?: ServerAccessInviteSuggestion[]
friendIds?: string[]
searchUsers?: (query: string) => Promise<ServerAccessInviteSuggestion[]>
canGrant?: boolean
permissionDeniedMessage?: string
}>(),
{
members: () => [],
suggestions: () => [],
friendIds: () => [],
canGrant: true,
},
)
const emit = defineEmits<{
grant: [payload: GrantServerAccessPayload]
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const target = ref('')
const selectedRole = ref<Exclude<ServerAccessRole, 'owner'>>('editor')
const addAsFriend = ref(true)
const suggestionMinimumLength = 1
const remoteSuggestions = ref<ServerAccessInviteSuggestion[]>([])
const targetLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
const targetLookupRequestId = ref(0)
const hasSelectedTarget = ref(false)
const targetComboboxOpen = ref(false)
const messages = defineMessages({
header: {
id: 'servers.grant-access-modal.header',
defaultMessage: 'Add user',
},
targetLabel: {
id: 'servers.grant-access-modal.target.label',
defaultMessage: 'Modrinth username',
},
targetPlaceholder: {
id: 'servers.grant-access-modal.target.placeholder',
defaultMessage: 'Enter Modrinth username',
},
noSuggestions: {
id: 'servers.grant-access-modal.target.no-suggestions',
defaultMessage: 'No matching users found.',
},
searching: {
id: 'servers.grant-access-modal.target.searching',
defaultMessage: 'Searching...',
},
targetHelp: {
id: 'servers.grant-access-modal.target.help',
defaultMessage: 'Do not use their Minecraft username.',
},
roleLabel: {
id: 'servers.grant-access-modal.role.label',
defaultMessage: 'Select role',
},
editorRole: {
id: 'servers.grant-access-modal.role.editor',
defaultMessage: 'Editor',
},
editorDescription: {
id: 'servers.grant-access-modal.role.editor-description',
defaultMessage: 'Manage instance content, files, backups, and other settings.',
},
viewerRole: {
id: 'servers.grant-access-modal.role.viewer',
defaultMessage: 'Limited',
},
viewerDescription: {
id: 'servers.grant-access-modal.role.viewer-description',
defaultMessage: 'Start, stop, and view the server without making changes.',
},
permissionsHelp: {
id: 'servers.grant-access-modal.permissions-help',
defaultMessage: 'View the full list of permissions for each role <link>here</link>.',
},
addAsFriend: {
id: 'servers.grant-access-modal.add-as-friend',
defaultMessage: 'Also send a friend request',
},
cancelButton: {
id: 'servers.grant-access-modal.cancel',
defaultMessage: 'Cancel',
},
inviteButton: {
id: 'servers.grant-access-modal.invite',
defaultMessage: 'Invite',
},
suggestionAvatarAlt: {
id: 'servers.grant-access-modal.suggestion-avatar-alt',
defaultMessage: "{username}'s avatar",
},
alreadyMemberTooltip: {
id: 'servers.grant-access-modal.already-member-tooltip',
defaultMessage: 'This user has already been invited',
},
})
const grantableRoles = computed(() => [
{
value: 'editor' as const,
label: formatMessage(messages.editorRole),
description: formatMessage(messages.editorDescription),
icon: PencilIcon,
},
{
value: 'viewer' as const,
label: formatMessage(messages.viewerRole),
description: formatMessage(messages.viewerDescription),
icon: EyeIcon,
},
])
const normalizedTarget = computed(() => target.value.trim())
const usesRemoteLookup = computed(() => !!props.searchUsers)
const matchedSuggestion = computed(() => findSuggestion(normalizedTarget.value))
const selectedTargetUserId = computed(() => matchedSuggestion.value?.id)
const friendIdSet = computed(() => new Set(props.friendIds.map((id) => id.toLowerCase())))
const targetIsFriend = computed(() => {
const userId = selectedTargetUserId.value
return !!userId && friendIdSet.value.has(userId.toLowerCase())
})
const hasResolvedTarget = computed(() => {
const suggestion = matchedSuggestion.value
if (!suggestion) return false
const normalizedSuggestionId = suggestion.id.toLowerCase()
const normalizedSuggestionUsername = suggestion.username.toLowerCase()
const normalizedValue = normalizedTarget.value.toLowerCase()
return (
hasSelectedTarget.value ||
normalizedSuggestionId === normalizedValue ||
normalizedSuggestionUsername === normalizedValue
)
})
const showAddAsFriend = computed(() => canAddAsFriend.value && !targetComboboxOpen.value)
const canAddAsFriend = computed(
() => hasResolvedTarget.value && !!matchedSuggestion.value && !targetIsFriend.value,
)
const existingMember = computed(() => findExistingMember())
const canInvite = computed(
() =>
normalizedTarget.value.length > 0 &&
!!selectedRole.value &&
!!matchedSuggestion.value &&
!existingMember.value &&
(!usesRemoteLookup.value || (targetLookupStatus.value === 'loaded' && hasResolvedTarget.value)),
)
const canSubmit = computed(() => props.canGrant && canInvite.value)
const permissionDeniedMessage = computed(
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
)
const grantPermissionTooltip = computed(() => {
if (!props.canGrant) return permissionDeniedMessage.value
if (existingMember.value) return formatMessage(messages.alreadyMemberTooltip)
return undefined
})
const targetLookupMessage = computed(() =>
usesRemoteLookup.value && targetLookupStatus.value !== 'loaded'
? formatMessage(messages.searching)
: formatMessage(messages.noSuggestions),
)
const inviteSuggestions = computed(() => {
const suggestions = new Map<string, ServerAccessInviteSuggestion>()
for (const suggestion of [...remoteSuggestions.value, ...props.suggestions]) {
suggestions.set(suggestion.id.toLowerCase(), suggestion)
suggestions.set(suggestion.username.toLowerCase(), suggestion)
}
return [...new Set(suggestions.values())]
})
const suggestionOptions = computed<ComboboxOption<string>[]>(() =>
inviteSuggestions.value.map((suggestion) => ({
value: suggestion.username,
label: suggestion.username,
searchTerms: [suggestion.username, suggestion.id, suggestion.email].filter(Boolean) as string[],
})),
)
function findSuggestion(value: string) {
const normalizedValue = value.trim().toLowerCase()
return inviteSuggestions.value.find(
(suggestion) =>
suggestion.username.toLowerCase() === normalizedValue ||
suggestion.id.toLowerCase() === normalizedValue ||
suggestion.email?.toLowerCase() === normalizedValue,
)
}
function findExistingMember() {
const normalizedValue = normalizedTarget.value.toLowerCase()
if (!normalizedValue) return undefined
const suggestion = matchedSuggestion.value
const normalizedSuggestionId = suggestion?.id.toLowerCase()
const normalizedSuggestionUsername = suggestion?.username.toLowerCase()
return props.members.find((member) => {
const normalizedMemberId = member.user.id.toLowerCase()
const normalizedMemberUsername = member.user.username.toLowerCase()
return (
normalizedMemberId === normalizedValue ||
normalizedMemberUsername === normalizedValue ||
(!!normalizedSuggestionId && normalizedMemberId === normalizedSuggestionId) ||
(!!normalizedSuggestionUsername && normalizedMemberUsername === normalizedSuggestionUsername)
)
})
}
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
const searchUsers = props.searchUsers
if (!searchUsers) return
try {
const users = await searchUsers(query)
if (requestId !== targetLookupRequestId.value || query !== normalizedTarget.value) return
remoteSuggestions.value = users
} catch {
if (requestId !== targetLookupRequestId.value || query !== normalizedTarget.value) return
remoteSuggestions.value = []
} finally {
if (requestId === targetLookupRequestId.value && query === normalizedTarget.value) {
targetLookupStatus.value = 'loaded'
}
}
}, 250)
function handleTargetSearch(value: string) {
target.value = value
remoteSuggestions.value = []
hasSelectedTarget.value = false
targetLookupRequestId.value += 1
if (!usesRemoteLookup.value) return
if (normalizedTarget.value.length < suggestionMinimumLength) {
targetLookupStatus.value = 'idle'
return
}
targetLookupStatus.value = 'loading'
void searchTargetUsers(normalizedTarget.value, targetLookupRequestId.value)
}
function handleTargetSelect(option: ComboboxOption<string>) {
target.value = option.value
hasSelectedTarget.value = true
}
function reset() {
target.value = ''
selectedRole.value = 'editor'
addAsFriend.value = true
remoteSuggestions.value = []
targetLookupStatus.value = 'idle'
targetLookupRequestId.value += 1
hasSelectedTarget.value = false
targetComboboxOpen.value = false
}
function show(event?: MouseEvent) {
reset()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
function submit() {
if (!canSubmit.value) return
const user = matchedSuggestion.value
if (!user) return
const payload: GrantServerAccessPayload = {
target: normalizedTarget.value,
user,
role: selectedRole.value,
addAsFriend: canAddAsFriend.value && addAsFriend.value,
}
hide()
emit('grant', payload)
}
defineExpose({ show, hide })
</script>
@@ -0,0 +1,321 @@
<template>
<NewModal
ref="modal"
:header="
formatMessage(modalState.shouldCancel ? messages.cancelHeader : messages.header, {
username: modalState.username,
})
"
max-width="470px"
>
<div class="flex flex-col gap-4">
<Admonition type="warning">
{{
formatMessage(
modalState.shouldCancel ? messages.cancelWarningBody : messages.warningBody,
{
username: modalState.username,
},
)
}}
</Admonition>
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
<Avatar
:src="modalState.avatarUrl"
:alt="formatMessage(messages.userAvatarAlt, { username: modalState.username })"
:tint-by="modalState.username"
size="40px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 truncate font-medium text-contrast">{{
modalState.username
}}</span>
<span
v-if="memberStatusLabel"
class="inline-flex h-6 shrink-0 items-center rounded-full border border-solid px-2 py-1 text-sm font-medium leading-none"
:class="memberStatusClasses"
>
{{ memberStatusLabel }}
</span>
</div>
<span class="truncate text-sm text-secondary">{{ memberSubtitle }}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whatHappensLabel)
}}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li
v-for="effect in effectMessages"
:key="effect.id"
class="leading-6 marker:text-secondary"
>
{{ formatMessage(effect) }}
</li>
</ul>
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button v-tooltip="removePermissionTooltip" :disabled="!canRemove" @click="confirm">
<TrashIcon v-if="modalState.shouldCancel" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
{{
formatMessage(modalState.shouldCancel ? messages.cancelButton : messages.removeButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, UserXIcon, XIcon } from '@modrinth/assets'
import { computed, ref, watch } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Admonition from '../../base/Admonition.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import type { ServerAccessRole } from './types'
const props = withDefaults(
defineProps<{
username: string
avatarUrl?: string
role?: ServerAccessRole
joinedAt?: string | null
pending?: boolean
shouldCancel?: boolean
canRemove?: boolean
permissionDeniedMessage?: string
}>(),
{
avatarUrl: undefined,
role: undefined,
joinedAt: null,
pending: false,
shouldCancel: false,
canRemove: true,
},
)
const emit = defineEmits<{
remove: []
}>()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const modal = ref<InstanceType<typeof NewModal>>()
const cachedState = ref({
username: '',
avatarUrl: undefined as string | undefined,
role: undefined as ServerAccessRole | undefined,
joinedAt: null as string | null,
pending: false,
shouldCancel: false,
})
const messages = defineMessages({
header: {
id: 'servers.remove-access-modal.header',
defaultMessage: 'Remove user',
},
cancelHeader: {
id: 'servers.remove-access-modal.cancel-header',
defaultMessage: 'Cancel invite',
},
warningBody: {
id: 'servers.remove-access-modal.warning-body',
defaultMessage:
"If you remove a user from your server, you'll need to re-invite them to restore access.",
},
cancelWarningBody: {
id: 'servers.remove-access-modal.cancel-warning-body',
defaultMessage:
'If you cancel this invite, {username} will need a new invitation before they can join this server.',
},
removeButton: {
id: 'servers.remove-access-modal.remove-button',
defaultMessage: 'Remove user',
},
cancelButton: {
id: 'servers.remove-access-modal.cancel-button',
defaultMessage: 'Cancel invite',
},
userAvatarAlt: {
id: 'servers.remove-access-modal.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
whatHappensLabel: {
id: 'servers.remove-access-modal.what-happens-label',
defaultMessage: 'What happens?',
},
removeEffectAccess: {
id: 'servers.remove-access-modal.remove-effect-access',
defaultMessage:
'They will immediately lose access to the server panel and will no longer be able to edit content',
},
removeEffectJoin: {
id: 'servers.remove-access-modal.remove-effect-join',
defaultMessage:
'They will still be able to join and play on the server unless you make separate changes',
},
cancelEffectAccess: {
id: 'servers.remove-access-modal.cancel-effect-access',
defaultMessage: 'They will not be added to this server',
},
cancelEffectInvite: {
id: 'servers.remove-access-modal.cancel-effect-invite',
defaultMessage: 'You can send them another invite later',
},
addedLabel: {
id: 'servers.remove-access-modal.added-label',
defaultMessage: 'Added {time}',
},
invitedLabel: {
id: 'servers.remove-access-modal.invited-label',
defaultMessage: 'Invited {time}',
},
pendingInviteLabel: {
id: 'servers.remove-access-modal.pending-invite-label',
defaultMessage: 'Pending invite',
},
unknownAddedLabel: {
id: 'servers.remove-access-modal.unknown-added-label',
defaultMessage: 'Added date unknown',
},
ownerRole: {
id: 'servers.access-role.owner',
defaultMessage: 'Owner',
},
editorRole: {
id: 'servers.access-role.editor',
defaultMessage: 'Editor',
},
viewerRole: {
id: 'servers.access-role.viewer',
defaultMessage: 'Limited',
},
})
const modalState = computed(() => (props.username ? currentState() : cachedState.value))
watch(
() =>
[
props.username,
props.avatarUrl,
props.role,
props.joinedAt,
props.pending,
props.shouldCancel,
] as const,
() => {
if (props.username) cachedState.value = currentState()
},
{ immediate: true },
)
const memberStatusLabel = computed(() => {
if (!modalState.value.role) return null
return formatRole(modalState.value.role)
})
const memberStatusClasses = computed(() => {
if (!modalState.value.role) return ''
return roleClasses(modalState.value.role)
})
const memberSubtitle = computed(() => {
if (modalState.value.shouldCancel || modalState.value.pending) {
return modalState.value.joinedAt
? formatMessage(messages.invitedLabel, {
time: formatRelativeTime(modalState.value.joinedAt),
})
: formatMessage(messages.pendingInviteLabel)
}
return modalState.value.joinedAt
? formatMessage(messages.addedLabel, { time: formatRelativeTime(modalState.value.joinedAt) })
: formatMessage(messages.unknownAddedLabel)
})
const effectMessages = computed(() =>
modalState.value.shouldCancel
? [messages.cancelEffectAccess, messages.cancelEffectInvite]
: [messages.removeEffectAccess, messages.removeEffectJoin],
)
const canRemove = computed(() => props.canRemove)
const permissionDeniedMessage = computed(
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
)
const removePermissionTooltip = computed(() =>
canRemove.value ? undefined : permissionDeniedMessage.value,
)
function currentState() {
return {
username: props.username,
avatarUrl: props.avatarUrl,
role: props.role,
joinedAt: props.joinedAt,
pending: props.pending,
shouldCancel: props.shouldCancel,
}
}
function formatRole(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return formatMessage(messages.ownerRole)
case 'editor':
return formatMessage(messages.editorRole)
case 'viewer':
return formatMessage(messages.viewerRole)
}
}
function roleClasses(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return 'border-orange bg-highlight-orange text-orange'
case 'editor':
return 'border-green bg-highlight-green text-brand'
case 'viewer':
return 'border-blue bg-highlight-blue text-blue'
}
}
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function confirm() {
if (!canRemove.value) return
hide()
emit('remove')
}
defineExpose({ show, hide })
</script>
@@ -0,0 +1,100 @@
<template>
<BaseEvent>
<span
v-if="isDeleted"
class="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1 whitespace-normal align-middle @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
>
<span class="shrink-0">{{ formatMessage(messages.deletedLabel) }}</span>
<EventEntityList
class="min-w-0"
:entities="addonEntities"
:limit="1"
single-line
entity-text-weight="semibold"
/>
</span>
<IntlFormatted v-else :message-id="message">
<template #content>
<EventEntityList
:entities="addonEntities"
:single-line="true"
:limit="contentLimit"
entity-text-weight="semibold"
/>
</template>
<template #files>
<EventEntityList :entities="fileNames ?? []" :single-line="true" :limit="1" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityList from './EventEntityList.vue'
import type { AuditAddonEventItem, EventEntity } from './types'
const props = defineProps<{
kind: string
addons?: AuditAddonEventItem[]
fileNames?: EventEntity[]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
added: {
id: 'servers.audit-log.event.addon-added',
defaultMessage: 'Added content <content></content>',
},
uploaded: {
id: 'servers.audit-log.event.addon-uploaded',
defaultMessage: 'Uploaded <files></files>',
},
disabled: {
id: 'servers.audit-log.event.addon-disabled',
defaultMessage: 'Disabled content <content></content>',
},
enabled: {
id: 'servers.audit-log.event.addon-enabled',
defaultMessage: 'Enabled content <content></content>',
},
deleted: {
id: 'servers.audit-log.event.addon-deleted',
defaultMessage: 'Deleted content <content></content>',
},
deletedLabel: {
id: 'servers.audit-log.event.addon-deleted-label',
defaultMessage: 'Deleted content',
},
updated: {
id: 'servers.audit-log.event.addon-updated',
defaultMessage: 'Updated content <content></content>',
},
changed: {
id: 'servers.audit-log.event.addon-changed',
defaultMessage: 'Changed content <content></content>',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
added: messages.added,
uploaded: messages.uploaded,
disabled: messages.disabled,
enabled: messages.enabled,
deleted: messages.deleted,
updated: messages.updated,
}
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
const addonEntities = computed(() => props.addons?.map((addon) => addon.project) ?? [])
const isDeleted = computed(() => props.kind === 'deleted')
const shouldShowSingleItem = computed(() =>
['added', 'disabled', 'enabled', 'updated'].includes(props.kind),
)
const contentLimit = computed(() => (shouldShowSingleItem.value ? 1 : undefined))
</script>
@@ -0,0 +1,105 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #backup>
<EventEntityLink v-if="backup" :entity="backup" />
</template>
<template #renamed-backup>
<EventEntityLink v-if="renamedBackup" :entity="renamedBackup" />
</template>
<template #from>
<EventInlineText :text="from ?? ''" class="align-middle font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import EventInlineText from './EventInlineText.vue'
import type { AuditBackupEventItem } from './types'
const props = defineProps<{
kind: string
backup?: AuditBackupEventItem
backupId?: string
from?: string
to?: string
}>()
const messages = defineMessages({
created: {
id: 'servers.audit-log.event.backup-created',
defaultMessage: 'Created backup <backup></backup>',
},
restored: {
id: 'servers.audit-log.event.backup-restored',
defaultMessage: 'Restored backup <backup></backup>',
},
renamed: {
id: 'servers.audit-log.event.backup-renamed',
defaultMessage: 'Renamed backup <from></from> to <renamed-backup></renamed-backup>',
},
deleted: {
id: 'servers.audit-log.event.backup-deleted',
defaultMessage: 'Deleted backup <backup></backup>',
},
changed: {
id: 'servers.audit-log.event.backup-changed',
defaultMessage: 'Changed backup <backup></backup>',
},
createdFallback: {
id: 'servers.audit-log.event.backup-created-fallback',
defaultMessage: 'Created backup',
},
restoredFallback: {
id: 'servers.audit-log.event.backup-restored-fallback',
defaultMessage: 'Restored backup',
},
renamedFallback: {
id: 'servers.audit-log.event.backup-renamed-fallback',
defaultMessage: 'Renamed backup',
},
deletedFallback: {
id: 'servers.audit-log.event.backup-deleted-fallback',
defaultMessage: 'Deleted backup',
},
changedFallback: {
id: 'servers.audit-log.event.backup-changed-fallback',
defaultMessage: 'Changed backup',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
created: messages.created,
restored: messages.restored,
renamed: messages.renamed,
deleted: messages.deleted,
}
const fallbackMessages: Record<string, MessageDescriptor> = {
created: messages.createdFallback,
restored: messages.restoredFallback,
renamed: messages.renamedFallback,
deleted: messages.deletedFallback,
}
const message = computed(() =>
props.backup
? (kindMessages[props.kind] ?? messages.changed)
: (fallbackMessages[props.kind] ?? messages.changedFallback),
)
const renamedBackup = computed(() =>
props.backup
? {
...props.backup,
label: props.to ?? props.backup.label,
}
: undefined,
)
</script>
@@ -0,0 +1,9 @@
<template>
<div class="min-w-0 text-primary">
<span
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-x-1 whitespace-normal align-middle leading-7 @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
>
<slot />
</span>
</div>
</template>
@@ -0,0 +1,80 @@
<template>
<BaseEvent>
{{ formatMessage(message) }}
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import BaseEvent from './BaseEvent.vue'
const props = defineProps<{
action: string
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
serverCreated: {
id: 'servers.audit-log.event.server-created',
defaultMessage: 'Created server',
},
serverReallocated: {
id: 'servers.audit-log.event.server-reallocated',
defaultMessage: 'Reallocated server',
},
serverRepaired: {
id: 'servers.audit-log.event.server-repaired',
defaultMessage: 'Repaired server',
},
serverReset: {
id: 'servers.audit-log.event.server-reset',
defaultMessage: 'Reset server',
},
serverStarted: {
id: 'servers.audit-log.event.server-started',
defaultMessage: 'Started server',
},
serverStopped: {
id: 'servers.audit-log.event.server-stopped',
defaultMessage: 'Stopped server',
},
serverRestarted: {
id: 'servers.audit-log.event.server-restarted',
defaultMessage: 'Restarted server',
},
serverKilled: {
id: 'servers.audit-log.event.server-killed',
defaultMessage: 'Killed server',
},
sftpLogin: {
id: 'servers.audit-log.event.sftp-login',
defaultMessage: 'Logged in via SFTP',
},
consoleCleared: {
id: 'servers.audit-log.event.console-cleared',
defaultMessage: 'Cleared console',
},
unknown: {
id: 'servers.audit-log.event.unknown-basic',
defaultMessage: 'Recorded server activity',
},
})
const actionMessages: Record<string, MessageDescriptor> = {
server_created: messages.serverCreated,
server_reallocated: messages.serverReallocated,
server_repaired: messages.serverRepaired,
server_reset: messages.serverReset,
server_started: messages.serverStarted,
server_stopped: messages.serverStopped,
server_restarted: messages.serverRestarted,
server_killed: messages.serverKilled,
sftp_login: messages.sftpLogin,
console_cleared: messages.consoleCleared,
}
const message = computed(() => actionMessages[props.action] ?? messages.unknown)
</script>
@@ -0,0 +1,142 @@
<template>
<BaseEvent>
<span
v-if="props.kind === 'properties'"
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1 whitespace-normal align-middle @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
>
<span class="shrink-0">{{ formatMessage(messages.propertiesModifiedLabel) }}</span>
<EventInlineText
:text="propertiesLabel"
class="align-middle font-mono text-[0.925em] font-medium text-contrast"
/>
</span>
<IntlFormatted v-else :message-id="message">
<template #version>
<EventInlineText :text="newVersion ?? ''" class="align-middle font-medium text-contrast" />
</template>
<template #loader>
<EventInlineText :text="newLoaderLabel" class="align-middle font-medium text-contrast" />
</template>
<template #command>
<EventInlineText
:text="command ?? ''"
class="align-middle font-mono font-medium text-contrast"
/>
</template>
<template #vendor>
<EventInlineText :text="vendor ?? ''" class="align-middle font-medium text-contrast" />
</template>
<template #java-version>
<EventInlineText :text="version ?? ''" class="align-middle font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventInlineText from './EventInlineText.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind:
| 'loader_version'
| 'game_version'
| 'properties'
| 'startup_command'
| 'java_runtime'
| 'java_version'
newVersion?: string | null
newLoader?: string | null
properties?: EventEntity[]
command?: string
vendor?: string
version?: number
}>()
const messages = defineMessages({
loaderVersionChanged: {
id: 'servers.audit-log.event.loader-version-changed',
defaultMessage: 'Changed loader version to <version></version>',
},
loaderChanged: {
id: 'servers.audit-log.event.loader-changed',
defaultMessage: 'Changed loader to <loader></loader>',
},
loaderAndVersionChanged: {
id: 'servers.audit-log.event.loader-and-version-changed',
defaultMessage: 'Changed loader to <loader></loader> <version></version>',
},
loaderVersionCleared: {
id: 'servers.audit-log.event.loader-version-cleared',
defaultMessage: 'Cleared loader version',
},
gameVersionChanged: {
id: 'servers.audit-log.event.game-version-changed',
defaultMessage: 'Changed Minecraft version to <version></version>',
},
propertiesModified: {
id: 'servers.audit-log.event.server-properties-modified',
defaultMessage: 'Modified server properties <properties></properties>',
},
propertiesModifiedLabel: {
id: 'servers.audit-log.event.server-properties-modified-label',
defaultMessage: 'Modified server properties',
},
startupCommandModified: {
id: 'servers.audit-log.event.startup-command-modified',
defaultMessage: 'Changed startup command to <command></command>',
},
javaRuntimeModified: {
id: 'servers.audit-log.event.java-runtime-modified',
defaultMessage: 'Changed Java runtime to <vendor></vendor>',
},
javaVersionModified: {
id: 'servers.audit-log.event.java-version-modified',
defaultMessage: 'Changed Java version to <java-version></java-version>',
},
configChanged: {
id: 'servers.audit-log.event.config-changed',
defaultMessage: 'Changed server configuration',
},
})
const { formatMessage } = useVIntl()
const propertiesLabel = computed(
() => props.properties?.map((property) => property.label).join(', ') ?? '',
)
const newLoader = computed(() =>
props.kind === 'loader_version' && props.newLoader == null ? 'vanilla' : props.newLoader,
)
const newLoaderLabel = computed(() => formatLoader(newLoader.value))
const kindMessages: Record<string, MessageDescriptor> = {
game_version: messages.gameVersionChanged,
properties: messages.propertiesModified,
startup_command: messages.startupCommandModified,
java_runtime: messages.javaRuntimeModified,
java_version: messages.javaVersionModified,
}
const message = computed(() => {
if (props.kind === 'loader_version') {
if (newLoader.value && props.newVersion) return messages.loaderAndVersionChanged
if (newLoader.value) return messages.loaderChanged
return props.newVersion == null ? messages.loaderVersionCleared : messages.loaderVersionChanged
}
return kindMessages[props.kind] ?? messages.configChanged
})
function formatLoader(loader: string | null | undefined): string {
if (!loader) return ''
return loader
.split(/[-_]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
</script>
@@ -0,0 +1,27 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="messages.commandExecuted">
<template #command>
<EventInlineText :text="command" class="align-middle font-mono font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { defineMessages } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventInlineText from './EventInlineText.vue'
defineProps<{
command: string
}>()
const messages = defineMessages({
commandExecuted: {
id: 'servers.audit-log.event.console-command-executed',
defaultMessage: 'Ran console command <command></command>',
},
})
</script>
@@ -0,0 +1,94 @@
<template>
<AutoLink
:to="entity.to"
class="inline-flex min-w-0 max-w-full flex-wrap items-center gap-0 @[800px]:flex-nowrap"
:class="[
stackSecondary ? '!grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 gap-y-0.5' : '',
entity.to
? `${textWeightClass} text-contrast hover:underline`
: entity.muted
? 'text-secondary'
: `${textWeightClass} text-contrast`,
entity.mono ? 'font-mono text-[0.925em]' : '',
'align-middle',
]"
>
<span
v-if="showAvatar || entity.icon"
class="inline-flex shrink-0 items-center justify-center"
:class="[
stackSecondary ? 'row-span-2 self-center' : 'mr-1',
entity.icon
? 'size-7 rounded-lg border border-solid border-surface-5 bg-surface-4 text-secondary'
: '',
]"
>
<Avatar
v-if="showAvatar"
:src="entity.iconUrl"
:alt="entity.label"
size="1.75rem"
no-shadow
raised
:tint-by="entity.label || entity.id"
:circle="entity.iconShape === 'circle'"
class="inline-flex shrink-0 border border-solid border-surface-5"
:class="entity.iconShape === 'circle' ? '!rounded-full' : '!rounded-lg'"
/>
<component :is="entity.icon" v-else class="size-4" />
</span>
<span
ref="labelRef"
v-tooltip="truncatedTooltip(labelRef, entity.title ?? entity.label)"
class="min-w-0 whitespace-normal break-words leading-7 @[800px]:truncate @[800px]:whitespace-nowrap"
:class="stackSecondary ? 'leading-6 @[800px]:whitespace-normal @[800px]:break-words' : ''"
>
{{ entity.label }}
</span>
<span
v-if="entity.secondaryLabel"
ref="secondaryLabelRef"
v-tooltip="truncatedTooltip(secondaryLabelRef, entity.secondaryLabel)"
class="min-w-0 whitespace-normal break-words text-secondary @[800px]:truncate @[800px]:whitespace-nowrap"
:class="
stackSecondary
? `col-start-2 leading-5 @[800px]:whitespace-normal @[800px]:break-words ${textWeightClass}`
: `entity-secondary-label ${textWeightClass}`
"
>
<template v-if="stackSecondary">{{ entity.secondaryLabel }}</template>
<template v-else>&nbsp;{{ entity.secondaryLabel }}</template>
</span>
</AutoLink>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import { truncatedTooltip } from '#ui/utils/truncate'
import type { EventEntity } from './types'
const props = withDefaults(
defineProps<{
entity: EventEntity
stackSecondary?: boolean
textWeight?: 'medium' | 'semibold'
}>(),
{
stackSecondary: false,
textWeight: 'medium',
},
)
const textWeightClass = computed(() =>
props.textWeight === 'semibold' ? 'font-semibold' : 'font-medium',
)
const showAvatar = computed(
() => props.entity.iconUrl != null || props.entity.iconShape === 'circle',
)
const labelRef = ref<HTMLElement | null>(null)
const secondaryLabelRef = ref<HTMLElement | null>(null)
</script>
@@ -0,0 +1,147 @@
<template>
<span
class="inline-flex max-w-full min-w-0 items-center gap-x-1 align-middle"
:class="
singleLine
? 'flex-wrap gap-y-0.5 whitespace-normal @[800px]:flex-nowrap @[800px]:overflow-hidden @[800px]:whitespace-nowrap'
: 'flex-wrap gap-y-0.5'
"
>
<template v-for="(entity, index) in visibleEntities" :key="entity.id">
<EventEntityLink
:entity="entity"
:text-weight="entityTextWeight"
:class="singleLine ? 'min-w-0 shrink' : ''"
/>
<span v-if="index < visibleEntities.length - 1" class="shrink-0 text-secondary">,</span>
</template>
<Tooltip
v-if="hiddenCount > 0"
theme="dismissable-prompt"
class="inline-flex shrink-0 items-center"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
popper-class="v-popper--interactive audit-log-entity-list-popper"
placement="top"
:delay="{ show: 200, hide: 100 }"
no-auto-focus
>
<button
type="button"
class="inline-flex min-w-0 cursor-help items-center rounded-full border border-solid border-surface-5 bg-surface-4 px-1.5 py-1 leading-none text-xs text-secondary"
:aria-label="hiddenTooltip"
>
{{ formatMessage(messages.hiddenCount, { count: hiddenCount }) }}
</button>
<template #popper>
<div class="relative max-w-[22rem]">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-3 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="hiddenEntitiesScrollContainer"
class="flex flex-col gap-2 overflow-y-auto overscroll-contain py-0.5"
:style="{ maxHeight: hiddenEntitiesMaxHeight }"
@scroll="checkScrollState"
>
<EventEntityLink
v-for="entity in hiddenEntities"
:key="entity.id"
:entity="entity"
:text-weight="entityTextWeight"
class="min-w-0 pr-4"
stack-secondary
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-3 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</template>
</Tooltip>
</span>
</template>
<script setup lang="ts">
import { Tooltip } from 'floating-vue'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../../composables/i18n'
import { useScrollIndicator } from '../../../../composables/scroll-indicator'
import EventEntityLink from './EventEntityLink.vue'
import type { EventEntity } from './types'
const props = withDefaults(
defineProps<{
entities: EventEntity[]
limit?: number
singleLine?: boolean
entityTextWeight?: 'medium' | 'semibold'
}>(),
{
limit: 3,
singleLine: true,
entityTextWeight: 'medium',
},
)
const { formatMessage, locale } = useVIntl()
const hiddenEntitiesScrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(
hiddenEntitiesScrollContainer,
)
const TOOLTIP_VISIBLE_ROWS = 8
const TOOLTIP_ENTITY_HEIGHT_REM = 1.75
const TOOLTIP_ENTITY_GAP_REM = 0.5
const hiddenEntitiesMaxHeight = `${
TOOLTIP_VISIBLE_ROWS * TOOLTIP_ENTITY_HEIGHT_REM +
(TOOLTIP_VISIBLE_ROWS - 1) * TOOLTIP_ENTITY_GAP_REM
}rem`
const messages = defineMessages({
hiddenCount: {
id: 'servers.audit-log.event.entity-list.hidden-count',
defaultMessage: '+{count, number}',
},
})
const visibleEntities = computed(() => props.entities.slice(0, props.limit))
const hiddenEntities = computed(() => props.entities.slice(props.limit))
const hiddenCount = computed(() => hiddenEntities.value.length)
const hiddenTooltip = computed(() => {
void locale.value
return new Intl.ListFormat(locale.value, {
style: 'long',
type: 'conjunction',
}).format(hiddenEntities.value.map((entity) => entity.label))
})
</script>
<style lang="scss">
.v-popper__popper.v-popper--theme-dismissable-prompt.audit-log-entity-list-popper {
.v-popper__inner {
padding-right: 0 !important;
}
}
</style>
@@ -0,0 +1,23 @@
<template>
<span
ref="textRef"
v-tooltip="truncatedTooltip(textRef, tooltipText)"
class="max-w-full min-w-0 whitespace-normal break-words @[800px]:truncate @[800px]:whitespace-nowrap"
>
<slot>{{ text }}</slot>
</span>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { truncatedTooltip } from '#ui/utils/truncate'
const props = defineProps<{
text: string | number
tooltip?: string
}>()
const textRef = ref<HTMLElement | null>(null)
const tooltipText = computed(() => props.tooltip ?? String(props.text))
</script>
@@ -0,0 +1,64 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #file>
<EventEntityLink v-if="file" :entity="file" />
</template>
<template #from>
<EventEntityLink v-if="from" :entity="from" />
</template>
<template #to>
<EventEntityLink v-if="to" :entity="to" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: string
file?: EventEntity
from?: EventEntity
to?: EventEntity
}>()
const messages = defineMessages({
uploaded: {
id: 'servers.audit-log.event.file-uploaded',
defaultMessage: 'Uploaded file <file></file>',
},
deleted: {
id: 'servers.audit-log.event.file-deleted',
defaultMessage: 'Deleted file <file></file>',
},
edited: {
id: 'servers.audit-log.event.file-edited',
defaultMessage: 'Edited file <file></file>',
},
renamed: {
id: 'servers.audit-log.event.file-renamed',
defaultMessage: 'Renamed <from></from> to <to></to>',
},
changed: {
id: 'servers.audit-log.event.file-changed',
defaultMessage: 'Changed file <file></file>',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
uploaded: messages.uploaded,
deleted: messages.deleted,
edited: messages.edited,
renamed: messages.renamed,
}
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
</script>
@@ -0,0 +1,68 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #modpack>
<EventEntityLink v-if="modpack" :entity="modpack" />
</template>
<template #version>
<EventInlineText :text="versionLabel" class="align-middle font-mono text-secondary" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import EventInlineText from './EventInlineText.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: 'changed' | 'unlinked'
modpack?: EventEntity | null
versionLabel?: string | null
}>()
const messages = defineMessages({
changed: {
id: 'servers.audit-log.event.modpack-changed',
defaultMessage: 'Changed modpack',
},
changedToModpack: {
id: 'servers.audit-log.event.modpack-changed-to-modpack',
defaultMessage: 'Changed modpack to <modpack></modpack>',
},
changedToVersion: {
id: 'servers.audit-log.event.modpack-changed-to-version',
defaultMessage: 'Changed modpack to version <version></version>',
},
unlinked: {
id: 'servers.audit-log.event.modpack-unlinked',
defaultMessage: 'Unlinked modpack',
},
unlinkedModpack: {
id: 'servers.audit-log.event.modpack-unlinked-modpack',
defaultMessage: 'Unlinked modpack <modpack></modpack>',
},
unlinkedVersion: {
id: 'servers.audit-log.event.modpack-unlinked-version',
defaultMessage: 'Unlinked modpack version <version></version>',
},
})
const message = computed(() => {
if (props.kind === 'unlinked') {
if (props.modpack) return messages.unlinkedModpack
return props.versionLabel ? messages.unlinkedVersion : messages.unlinked
}
if (props.modpack) return messages.changedToModpack
return props.versionLabel ? messages.changedToVersion : messages.changed
})
const versionLabel = computed(() => props.versionLabel ?? '')
</script>
@@ -0,0 +1,35 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #port>
<span class="font-mono font-medium text-contrast">{{ port }}</span>
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
const props = defineProps<{
kind: 'added' | 'removed'
port: number
}>()
const messages = defineMessages({
added: {
id: 'servers.audit-log.event.port-allocation-added',
defaultMessage: 'Added port allocation <port></port>',
},
removed: {
id: 'servers.audit-log.event.port-allocation-removed',
defaultMessage: 'Removed port allocation <port></port>',
},
})
const message = computed(() => (props.kind === 'added' ? messages.added : messages.removed))
</script>
@@ -0,0 +1,124 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #name>
<EventEntityLink :entity="nameEntity" />
</template>
<template #subdomain>
<EventEntityLink :entity="subdomainEntity" />
</template>
<template #specs>
<EventInlineText :text="specsLabel" class="align-middle font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import EventInlineText from './EventInlineText.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: 'name' | 'subdomain' | 'plan'
name?: string
subdomain?: string
newSpecs?: Record<string, unknown>
}>()
const { formatMessage, locale } = useVIntl()
const messages = defineMessages({
nameChanged: {
id: 'servers.audit-log.event.server-name-changed',
defaultMessage: 'Changed server name to <name></name>',
},
subdomainChanged: {
id: 'servers.audit-log.event.server-subdomain-changed',
defaultMessage: 'Changed server subdomain to <subdomain></subdomain>',
},
planChanged: {
id: 'servers.audit-log.event.server-plan-changed',
defaultMessage: 'Changed plan to <specs></specs>',
},
changed: {
id: 'servers.audit-log.event.server-metadata-changed',
defaultMessage: 'Changed server metadata',
},
cpuSpec: {
id: 'servers.audit-log.event.server-plan.cpu',
defaultMessage: '{count, plural, one {# CPU} other {# CPUs}}',
},
ramGb: {
id: 'servers.audit-log.event.server-plan.ram-gb',
defaultMessage: '{amount, number} GB RAM',
},
ramMb: {
id: 'servers.audit-log.event.server-plan.ram-mb',
defaultMessage: '{amount, number} MB RAM',
},
storageGb: {
id: 'servers.audit-log.event.server-plan.storage-gb',
defaultMessage: '{amount, number} GB storage',
},
storageMb: {
id: 'servers.audit-log.event.server-plan.storage-mb',
defaultMessage: '{amount, number} MB storage',
},
newPlan: {
id: 'servers.audit-log.event.server-plan.new-plan',
defaultMessage: 'new plan',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
name: messages.nameChanged,
subdomain: messages.subdomainChanged,
plan: messages.planChanged,
}
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
const nameEntity = computed<EventEntity>(() => ({
id: props.name ?? '',
label: props.name ?? '',
}))
const subdomainEntity = computed<EventEntity>(() => ({
id: props.subdomain ?? '',
label: props.subdomain ?? '',
mono: true,
}))
const specsLabel = computed(() => {
void locale.value
const cpu = numberValue(props.newSpecs?.cpu)
const memory = numberValue(props.newSpecs?.memory_mb)
const storage = numberValue(props.newSpecs?.storage_mb)
const parts = []
if (cpu != null) parts.push(formatMessage(messages.cpuSpec, { count: cpu }))
if (memory != null) parts.push(formatMemoryMb(memory))
if (storage != null) parts.push(formatStorageMb(storage))
return parts.length > 0 ? parts.join(' / ') : formatMessage(messages.newPlan)
})
function numberValue(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function formatMemoryMb(value: number): string {
if (value >= 1024) return formatMessage(messages.ramGb, { amount: Math.round(value / 1024) })
return formatMessage(messages.ramMb, { amount: value })
}
function formatStorageMb(value: number): string {
if (value >= 1024) {
return formatMessage(messages.storageGb, { amount: Math.round(value / 1024) })
}
return formatMessage(messages.storageMb, { amount: value })
}
</script>
@@ -0,0 +1,25 @@
<template>
<BaseEvent>
<span class="mr-1 text-secondary">{{ formatMessage(messages.unknownEvent) }}</span>
<EventInlineText :text="rawAction" class="font-mono text-secondary" />
</BaseEvent>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '../../../../composables/i18n'
import BaseEvent from './BaseEvent.vue'
import EventInlineText from './EventInlineText.vue'
defineProps<{
rawAction: string
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
unknownEvent: {
id: 'servers.audit-log.event.unknown',
defaultMessage: 'Unknown event',
},
})
</script>
@@ -0,0 +1,123 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message" :values="{ permissions: permissionLabel }">
<template #target-user>
<EventEntityLink :entity="targetUser" />
</template>
<template #permission-label="{ children }">
<span
v-if="permissionRole"
class="inline-flex h-7 items-center rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none align-middle"
:class="roleClasses(permissionRole)"
>
<component :is="() => children" />
</span>
<component :is="() => children" v-else />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import { apiPermissionsToAccessRole } from '../permissions'
import type { ServerAccessRole } from '../types'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: 'invited' | 'invite_revoked' | 'permission_modified' | 'removed'
targetUser: EventEntity
permissions?: Archon.ServerUsers.v1.UserScope | null
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
invited: {
id: 'servers.audit-log.event.user-invited',
defaultMessage: 'Invited <target-user></target-user>',
},
invitedWithPermissions: {
id: 'servers.audit-log.event.user-invited-with-permissions',
defaultMessage:
'Invited <target-user></target-user> with <permission-label>{permissions}</permission-label>',
},
inviteRevoked: {
id: 'servers.audit-log.event.user-invite-revoked',
defaultMessage: 'Revoked invite for <target-user></target-user>',
},
permissionModified: {
id: 'servers.audit-log.event.user-permission-modified',
defaultMessage: 'Changed permissions for <target-user></target-user>',
},
permissionModifiedWithPermissions: {
id: 'servers.audit-log.event.user-permission-modified-with-permissions',
defaultMessage:
'Changed permissions for <target-user></target-user> to <permission-label>{permissions}</permission-label>',
},
removed: {
id: 'servers.audit-log.event.user-removed',
defaultMessage: 'Removed <target-user></target-user>',
},
ownerRole: {
id: 'servers.access-role.owner',
defaultMessage: 'Owner',
},
editorRole: {
id: 'servers.access-role.editor',
defaultMessage: 'Editor',
},
viewerRole: {
id: 'servers.access-role.viewer',
defaultMessage: 'Limited',
},
})
const roleMessages: Record<ServerAccessRole, MessageDescriptor> = {
owner: messages.ownerRole,
editor: messages.editorRole,
viewer: messages.viewerRole,
}
const message = computed(() => {
if (props.kind === 'invited') {
return permissionLabel.value ? messages.invitedWithPermissions : messages.invited
}
if (props.kind === 'invite_revoked') return messages.inviteRevoked
if (props.kind === 'permission_modified') {
return permissionLabel.value
? messages.permissionModifiedWithPermissions
: messages.permissionModified
}
return messages.removed
})
const permissionLabel = computed(() => {
const role = permissionRole.value
if (role) return formatMessage(roleMessages[role])
return ''
})
const permissionRole = computed(() =>
props.permissions == null || props.permissions === ''
? null
: apiPermissionsToAccessRole(props.permissions),
)
function roleClasses(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return 'border-orange bg-highlight-orange text-orange'
case 'editor':
return 'border-green bg-highlight-green text-brand'
case 'viewer':
return 'border-blue bg-highlight-blue text-blue'
}
}
</script>
@@ -0,0 +1,16 @@
export { default as AddonEvent } from './AddonEvent.vue'
export { default as BackupEvent } from './BackupEvent.vue'
export { default as BaseEvent } from './BaseEvent.vue'
export { default as BasicStringEvent } from './BasicStringEvent.vue'
export { default as ConfigEvent } from './ConfigEvent.vue'
export { default as ConsoleEvent } from './ConsoleEvent.vue'
export { default as EventEntityLink } from './EventEntityLink.vue'
export { default as EventEntityList } from './EventEntityList.vue'
export { default as FileEvent } from './FileEvent.vue'
export { default as ModpackEvent } from './ModpackEvent.vue'
export { default as NetworkEvent } from './NetworkEvent.vue'
export * from './parser'
export { default as ServerMetaEvent } from './ServerMetaEvent.vue'
export * from './types'
export { default as UnknownEvent } from './UnknownEvent.vue'
export { default as UserAccessEvent } from './UserAccessEvent.vue'
@@ -0,0 +1,609 @@
import type { Archon } from '@modrinth/api-client'
import { PackageIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import AddonEvent from './AddonEvent.vue'
import BackupEvent from './BackupEvent.vue'
import BasicStringEvent from './BasicStringEvent.vue'
import ConfigEvent from './ConfigEvent.vue'
import ConsoleEvent from './ConsoleEvent.vue'
import FileEvent from './FileEvent.vue'
import ModpackEvent from './ModpackEvent.vue'
import NetworkEvent from './NetworkEvent.vue'
import ServerMetaEvent from './ServerMetaEvent.vue'
import type {
AuditActor,
AuditAddonEventItem,
AuditBackupEventItem,
AuditEventLookups,
AuditWorld,
BaseEventProps,
EventEntity,
ParsedAuditEvent,
} from './types'
import UnknownEvent from './UnknownEvent.vue'
import UserAccessEvent from './UserAccessEvent.vue'
const basicEvents = new Set([
'server_created',
'server_reallocated',
'server_repaired',
'server_reset',
'server_started',
'server_stopped',
'server_restarted',
'server_killed',
'sftp_login',
'console_cleared',
])
export function parseAuditEvent(
entry: Archon.Actions.v1.ActionEntry,
lookups: AuditEventLookups,
): ParsedAuditEvent {
const action = entry.action?.action || 'unknown'
const metadata = entry.action?.metadata
const base = baseProps(entry, lookups, action)
try {
if (basicEvents.has(action)) {
return parsed(BasicStringEvent, base, {}, actionSearchParts(action))
}
switch (action) {
case 'changed_server_name': {
const record = metadataRecord(metadata)
const name = stringField(record, 'name')
if (!name) return unknown(base, action)
return parsed(
ServerMetaEvent,
base,
{ kind: 'name', name },
actionSearchParts(action, name),
)
}
case 'changed_server_subdomain': {
const record = metadataRecord(metadata)
const subdomain = stringField(record, 'subdomain')
if (!subdomain) return unknown(base, action)
return parsed(ServerMetaEvent, base, { kind: 'subdomain', subdomain }, [
...actionSearchParts(action),
subdomain,
])
}
case 'server_plan_changed': {
const record = metadataRecord(metadata)
const newSpecs = objectField(record, 'new_specs')
if (!newSpecs) return unknown(base, action)
return parsed(ServerMetaEvent, base, { kind: 'plan', newSpecs }, [
...actionSearchParts(action),
...Object.values(newSpecs).map(String),
])
}
case 'user_invited':
case 'user_permission_modified': {
const actionMetadata = userPermissionsActionMetadata(metadataRecord(metadata))
if (!actionMetadata) return unknown(base, action)
const kind = action === 'user_invited' ? 'invited' : 'permission_modified'
const targetUser = userEntity(actionMetadata.user_id, lookups.users)
return parsed(
UserAccessEvent,
base,
{ kind, targetUser, permissions: actionMetadata.permissions },
[...actionSearchParts(action), targetUser.label, actionMetadata.permissions],
)
}
case 'user_invite_revoked':
case 'user_removed': {
const record = metadataRecord(metadata)
const userId = stringField(record, 'user_id')
if (!userId) return unknown(base, action)
const kind = action === 'user_invite_revoked' ? 'invite_revoked' : 'removed'
const targetUser = userEntity(userId, lookups.users)
return parsed(UserAccessEvent, base, { kind, targetUser }, [
...actionSearchParts(action),
targetUser.label,
])
}
case 'addon_added':
case 'addon_disabled':
case 'addon_enabled':
case 'addon_deleted':
case 'addon_updated': {
const addons = addonList(metadataRecord(metadata), lookups)
if (!addons) return unknown(base, action)
const kind = action.replace('addon_', '')
return parsed(AddonEvent, base, { kind, addons }, [
...actionSearchParts(action),
...addons.flatMap((addon) => [
addon.project.label,
addon.addonId,
addon.versionId,
addon.versionLabel,
]),
])
}
case 'addon_uploaded': {
const fileNames = stringArrayField(metadataRecord(metadata), 'file_names')
if (!fileNames) return unknown(base, action)
const files = fileNames.map((name) => fileEntity(name, lookups.serverId, false))
return parsed(AddonEvent, base, { kind: 'uploaded', fileNames: files }, [
...actionSearchParts(action),
...fileNames,
])
}
case 'modpack_changed': {
const record = metadataRecord(metadata)
const modpack = modpackEntityFromMetadata(record, lookups)
const versionLabel = modpack ? null : modpackVersionLabelFromMetadata(record, lookups)
return parsed(ModpackEvent, base, { kind: 'changed', modpack, versionLabel }, [
...actionSearchParts(action),
modpack?.id,
modpack?.label,
modpack?.secondaryLabel,
versionLabel,
])
}
case 'modpack_unlinked': {
const record = metadataRecord(metadata)
const modpack = modpackEntityFromMetadata(record, lookups)
const versionLabel = modpack ? null : modpackVersionLabelFromMetadata(record, lookups)
return parsed(ModpackEvent, base, { kind: 'unlinked', modpack, versionLabel }, [
...actionSearchParts(action),
modpack?.id,
modpack?.label,
modpack?.secondaryLabel,
versionLabel,
])
}
case 'port_allocation_added':
case 'port_allocation_removed': {
const port = numberField(metadataRecord(metadata), 'port')
if (port == null) return unknown(base, action)
return parsed(
NetworkEvent,
base,
{ kind: action === 'port_allocation_added' ? 'added' : 'removed', port },
[...actionSearchParts(action), String(port)],
)
}
case 'loader_version_edited': {
const record = metadataRecord(metadata)
if (!record || !('new_version' in record)) return unknown(base, action)
const newLoader = record.new_loader == null ? null : valueToString(record.new_loader)
const newVersion = record.new_version == null ? null : valueToString(record.new_version)
return parsed(ConfigEvent, base, { kind: 'loader_version', newLoader, newVersion }, [
...actionSearchParts(action),
newLoader,
newVersion,
])
}
case 'game_version_edited': {
const newVersion = stringField(metadataRecord(metadata), 'new_version')
if (!newVersion) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'game_version', newVersion }, [
...actionSearchParts(action),
newVersion,
])
}
case 'server_properties_modified': {
const properties = objectField(metadataRecord(metadata), 'properties')
if (!properties) return unknown(base, action)
const items = Object.entries(properties).map(
([key, value]): EventEntity => ({
id: key,
label: `${key}: ${valueToString(value) ?? ''}`,
mono: true,
}),
)
return parsed(ConfigEvent, base, { kind: 'properties', properties: items }, [
...actionSearchParts(action),
...items.map((item) => item.label),
])
}
case 'startup_command_modified': {
const command = stringField(metadataRecord(metadata), 'command')
if (!command) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'startup_command', command }, [
...actionSearchParts(action),
command,
])
}
case 'java_runtime_modified': {
const vendor = stringField(metadataRecord(metadata), 'vendor')
if (!vendor) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'java_runtime', vendor }, [
...actionSearchParts(action),
vendor,
])
}
case 'java_version_modified': {
const version = numberField(metadataRecord(metadata), 'version')
if (version == null) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'java_version', version }, [
...actionSearchParts(action),
String(version),
])
}
case 'file_uploaded':
case 'file_deleted':
case 'file_edited': {
const path = stringField(metadataRecord(metadata), 'path')
if (!path) return unknown(base, action)
const kind = action.replace('file_', '')
return parsed(FileEvent, base, { kind, file: fileEntity(path, lookups.serverId) }, [
...actionSearchParts(action),
path,
])
}
case 'file_renamed': {
const record = metadataRecord(metadata)
const from = stringField(record, 'from')
const to = stringField(record, 'to')
if (!from || !to) return unknown(base, action)
return parsed(
FileEvent,
base,
{
kind: 'renamed',
from: fileEntity(from, lookups.serverId),
to: fileEntity(to, lookups.serverId),
},
[...actionSearchParts(action), from, to],
)
}
case 'console_command_executed': {
const command = stringField(metadataRecord(metadata), 'command')
if (!command) return unknown(base, action)
return parsed(ConsoleEvent, base, { command }, [...actionSearchParts(action), command])
}
case 'backup_created':
case 'backup_restored':
case 'backup_deleted': {
const id = stringField(metadataRecord(metadata), 'id')
if (!id) return unknown(base, action)
const kind = action.replace('backup_', '')
const backup = backupEntity(id, lookups)
return parsed(BackupEvent, base, { kind, backup: backup ?? undefined, backupId: id }, [
...actionSearchParts(action),
backup?.label,
id,
])
}
case 'backup_renamed': {
const record = metadataRecord(metadata)
const id = stringField(record, 'id')
const from = stringField(record, 'from')
const to = stringField(record, 'to')
if (!id || !from || !to) return unknown(base, action)
const backup = backupEntity(id, lookups)
return parsed(
BackupEvent,
base,
{ kind: 'renamed', backup: backup ?? undefined, backupId: id, from, to },
[...actionSearchParts(action), backup?.label, from, to, id],
)
}
default:
return unknown(base, action)
}
} catch {
return unknown(base, action)
}
}
function baseProps(
entry: Archon.Actions.v1.ActionEntry,
lookups: AuditEventLookups,
action: string,
): BaseEventProps {
return {
action,
timestamp: entry.timestamp,
actor: actorFromEntry(entry.actor, lookups.users),
world: worldFromId(entry.world_id ?? null, lookups.worldById),
}
}
function parsed(
component: Component,
base: BaseEventProps,
props: Record<string, unknown>,
searchParts: unknown[],
): ParsedAuditEvent {
return {
key: base.action,
component,
props: { ...base, ...props },
searchText: searchParts
.filter((part): part is string => typeof part === 'string' && part.length > 0)
.join(' ')
.toLowerCase(),
}
}
function unknown(base: BaseEventProps, rawAction: string): ParsedAuditEvent {
return parsed(UnknownEvent, base, { rawAction }, [rawAction])
}
function actionSearchParts(action: string, ...extra: unknown[]): unknown[] {
return [action, action.replaceAll('_', ' '), ...extra]
}
function actorFromEntry(
actor: Archon.Actions.v1.ActionUser,
users: Record<string, Archon.Actions.v1.UserResp>,
): AuditActor {
if (actor.type === 'support') {
const user = actor.user_id ? users[actor.user_id] : undefined
return {
id: 'support',
username: user?.username ? `Support (${user.username})` : 'support',
}
}
const user = users[actor.user_id]
return {
id: actor.user_id,
username: user?.username ?? actor.user_id,
avatarUrl: user?.avatar_url || undefined,
profilePath: user?.username ? `/user/${encodeURIComponent(user.username)}` : undefined,
}
}
function userPermissionsActionMetadata(
record: Record<string, unknown> | null,
): Archon.Actions.v1.UserPermissionsActionMetadata | null {
const userId = stringField(record, 'user_id')
if (!userId) return null
return {
user_id: userId,
permissions: permissionField(record?.permissions),
}
}
function permissionField(value: unknown): Archon.ServerUsers.v1.UserScope | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') return value.trim() || null
if (Array.isArray(value)) {
const permissions = value
.filter((permission): permission is string => typeof permission === 'string')
.map((permission) => permission.trim())
.filter(Boolean)
return permissions.length > 0 ? permissions.join(' | ') : null
}
return null
}
function worldFromId(
worldId: string | null,
worldById: Map<string, AuditWorld>,
): AuditWorld | null {
if (!worldId) return null
return worldById.get(worldId) ?? { id: worldId, name: worldId }
}
function userEntity(
userId: string,
users: Record<string, Archon.Actions.v1.UserResp>,
): EventEntity {
const user = users[userId]
const label = user?.username ?? userId
return {
id: userId,
label,
iconUrl: user?.avatar_url || undefined,
iconShape: 'circle',
to: user?.username ? `/user/${encodeURIComponent(user.username)}` : undefined,
}
}
function addonList(
record: Record<string, unknown> | null,
lookups: AuditEventLookups,
): AuditAddonEventItem[] | null {
const list = arrayField(record, 'addons')
if (!list) return null
const addons: AuditAddonEventItem[] = []
for (const item of list) {
const addonRecord = metadataRecord(item)
const addonId = stringField(addonRecord, 'addon_id')
const versionId = stringField(addonRecord, 'version_id')
if (!addonId || !versionId) return null
addons.push(addonEntity(addonId, versionId, lookups.addons, lookups.versions))
}
return addons
}
function addonEntity(
addonId: string,
versionId: string,
addons: Record<string, Archon.Actions.v1.AddonResp>,
versions: Record<string, Archon.Actions.v1.VersionResp>,
): AuditAddonEventItem {
const addon = addons[addonId]
const versionLabel = resolveVersionLabel(versionId, versions)
const projectIdOrSlug = addon?.slug || addonId
return {
addonId,
versionId,
versionLabel,
project: {
id: addonId,
label: addon?.title || shortId(addonId),
secondaryLabel: versionLabel,
icon: PackageIcon,
iconUrl: addon?.icon_url || undefined,
iconShape: 'square',
to: `/project/${encodeURIComponent(projectIdOrSlug)}/version/${encodeURIComponent(versionId)}`,
},
}
}
function resolveVersionLabel(
versionId: string,
versions: Record<string, Archon.Actions.v1.VersionResp>,
): string {
const version = versions[versionId]
return version?.version_number || version?.name || shortId(versionId)
}
function backupEntity(id: string, lookups: AuditEventLookups): AuditBackupEventItem | null {
const backup = lookups.backupById.get(id)
if (!backup) return null
return {
id,
backupId: id,
found: true,
label: backup.name,
to: {
path: `/hosting/manage/${lookups.serverId}/backups`,
query: { backup: id },
},
}
}
function fileEntity(path: string, serverId: string, link = true): EventEntity {
return {
id: path,
label: path,
mono: true,
to: link
? {
path: `/hosting/manage/${serverId}/files`,
query: {
path: parentPath(path),
editing: path,
},
}
: undefined,
}
}
function parentPath(path: string): string {
const normalized = path.startsWith('/') ? path : `/${path}`
const lastSlash = normalized.lastIndexOf('/')
if (lastSlash <= 0) return '/'
return normalized.slice(0, lastSlash)
}
function metadataRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function objectField(
record: Record<string, unknown> | null,
key: string,
): Record<string, unknown> | null {
return metadataRecord(record?.[key])
}
function arrayField(record: Record<string, unknown> | null, key: string): unknown[] | null {
const value = record?.[key]
return Array.isArray(value) ? value : null
}
function stringArrayField(record: Record<string, unknown> | null, key: string): string[] | null {
const array = arrayField(record, key)
if (!array || !array.every((item) => typeof item === 'string')) return null
return array
}
function modpackEntityFromMetadata(
record: Record<string, unknown> | null,
lookups: AuditEventLookups,
): EventEntity | null {
const spec = metadataRecord(record?.spec)
if (!spec) return null
const platform = stringField(spec, 'platform')
if (platform === 'modrinth') {
const projectId = stringField(spec, 'project_id')
const versionId = stringField(spec, 'version_id')
if (!projectId && !versionId) return null
const project = projectId ? lookups.addons[projectId] : undefined
const versionLabel = versionId ? resolveVersionLabel(versionId, lookups.versions) : undefined
const projectIdOrSlug = project?.slug || projectId
const label = project?.title || (projectId ? shortId(projectId) : versionLabel)
return {
id: projectId || versionId || 'modrinth',
label: label || 'Modrinth modpack',
secondaryLabel: versionLabel,
icon: PackageIcon,
iconUrl: project?.icon_url || undefined,
iconShape: 'square',
to: projectIdOrSlug
? versionId
? `/project/${encodeURIComponent(projectIdOrSlug)}/version/${encodeURIComponent(versionId)}`
: `/project/${encodeURIComponent(projectIdOrSlug)}`
: undefined,
title: project?.title ? undefined : projectId || versionId || undefined,
}
}
if (platform === 'local_file') {
const filename = stringField(spec, 'filename')
const name = stringField(spec, 'name')
const versionId = stringField(spec, 'version_id')
if (!filename && !name && !versionId) return null
return {
id: filename || name || versionId || 'local-file',
label: name || filename || versionId || 'Local modpack',
secondaryLabel: name && filename ? filename : versionId || undefined,
icon: PackageIcon,
iconShape: 'square',
mono: !name,
title: filename || name || undefined,
}
}
return null
}
function modpackVersionLabelFromMetadata(
record: Record<string, unknown> | null,
lookups: AuditEventLookups,
): string | null {
const direct = valueToString(record?.new_version)
if (direct != null) return direct
const spec = metadataRecord(record?.spec)
if (!spec) return null
const versionId = valueToString(spec.version_id)
if (versionId != null) return resolveVersionLabel(versionId, lookups.versions)
if (spec.platform === 'local_file') {
return stringField(spec, 'name') ?? stringField(spec, 'filename')
}
return null
}
function stringField(record: Record<string, unknown> | null, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function numberField(record: Record<string, unknown> | null, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function valueToString(value: unknown): string | null {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return null
}
function shortId(id: string): string {
if (id.length <= 12) return id
return id.slice(0, 8)
}
@@ -0,0 +1,69 @@
import type { Archon } from '@modrinth/api-client'
import type { Component } from 'vue'
export interface AuditActor {
id: string
username: string
avatarUrl?: string
profilePath?: string
}
export interface AuditWorld {
id: string
name: string
}
export interface BaseEventProps {
action: string
timestamp: string
actor: AuditActor
world: AuditWorld | null
}
export type EventRoute =
| string
| {
path: string
query?: Record<string, string | undefined>
}
export interface EventEntity {
id: string
label: string
secondaryLabel?: string
to?: EventRoute
icon?: Component
iconUrl?: string | null
iconShape?: 'circle' | 'square'
mono?: boolean
muted?: boolean
title?: string
}
export interface AuditAddonEventItem {
addonId: string
versionId: string
project: EventEntity
versionLabel: string
}
export interface AuditBackupEventItem extends EventEntity {
backupId: string
found: boolean
}
export interface ParsedAuditEvent {
key: string
component: Component
props: BaseEventProps & Record<string, unknown>
searchText: string
}
export interface AuditEventLookups {
serverId: string
users: Record<string, Archon.Actions.v1.UserResp>
addons: Record<string, Archon.Actions.v1.AddonResp>
versions: Record<string, Archon.Actions.v1.VersionResp>
worldById: Map<string, AuditWorld>
backupById: Map<string, Archon.Backups.v1.Backup>
}
@@ -0,0 +1,7 @@
export { default as AccessTable } from './AccessTable.vue'
export { default as AuditLogTable } from './AuditLogTable.vue'
export * from './events'
export { default as GrantAccessModal } from './GrantAccessModal.vue'
export * from './permissions'
export { default as RemoveAccessModal } from './RemoveAccessModal.vue'
export * from './types'
@@ -0,0 +1,23 @@
import type { Archon } from '@modrinth/api-client'
import { hasServerPermission } from '../../../composables/server-permissions'
import type { ServerAccessRole } from './types'
export function apiPermissionsToAccessRole(
permissions: Archon.ServerUsers.v1.UserScope,
): ServerAccessRole {
if (hasServerPermission(permissions, 'SERVER_ADMIN')) {
return 'owner'
}
if (
hasServerPermission(permissions, 'EXEC_COMMANDS') ||
hasServerPermission(permissions, 'FILES_WRITE') ||
hasServerPermission(permissions, 'SETUP') ||
hasServerPermission(permissions, 'BACKUPS') ||
hasServerPermission(permissions, 'ADVANCED') ||
hasServerPermission(permissions, 'RESET_SERVER')
) {
return 'editor'
}
return 'viewer'
}
@@ -0,0 +1,57 @@
import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
export interface ServerAccessUser extends AuditActor {
id: string
username: string
avatarUrl?: string
}
export interface ServerAccessMember {
id: string
user: ServerAccessUser
role: ServerAccessRole
joinedAt: string | null
inviteResendAvailableAt?: string | null
pending?: boolean
isOwner?: boolean
}
export interface ServerAuditLogEntry {
id: string
actor: AuditActor
world: AuditWorld | null
event: ParsedAuditEvent
timestamp: string
}
export interface ServerAuditLogFilters {
userId: string | null
worldId: string | null
}
export interface ServerAccessRoleOption {
value: ServerAccessRole
label: string
description?: string
}
export interface ServerAccessInviteSuggestion {
id: string
username: string
avatarUrl?: string
email?: string
}
export interface GrantServerAccessPayload {
target: string
user: ServerAccessInviteSuggestion
role: Exclude<ServerAccessRole, 'owner'>
addAsFriend: boolean
}
export interface ServerListingOwner {
username: string
avatarUrl?: string
}
@@ -32,6 +32,8 @@ defineProps<{
item: BackupAdmonitionEntry
dismissible: boolean
cancelling: boolean
canManageBackups?: boolean
permissionDeniedMessage?: string
}>()
defineEmits<{
@@ -278,12 +280,24 @@ function getDescription(item: BackupAdmonitionEntry): string {
</div>
<template #top-right-actions>
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
<button
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="cancelling || canManageBackups === false"
@click="$emit('cancel')"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
<button class="!border" type="button" @click="$emit('retry')">
<button
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="canManageBackups === false"
@click="$emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
@@ -25,7 +25,13 @@
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
<button class="!border" type="button" @click="ctx.dismissOperation(op.id!, 'cancel')">
<button
v-tooltip="!canWriteFiles ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="!canWriteFiles"
@click="cancelOperation"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
@@ -41,6 +47,7 @@ import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
@@ -55,6 +62,7 @@ const props = defineProps<{
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const { canWriteFiles, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
extracting: {
@@ -97,4 +105,9 @@ const title = computed(() => {
}
return formatMessage(messages.extracting, { source: sourceName.value })
})
function cancelOperation() {
if (!canWriteFiles.value || !props.op.id) return
ctx.dismissOperation(props.op.id, 'cancel')
}
</script>
@@ -12,6 +12,7 @@ import InstallingBanner, {
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -32,6 +33,7 @@ const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const ctx = injectModrinthServerContext()
const route = useRoute()
const { canSetup, canManageBackups, permissionDeniedMessage } = useServerPermissions()
const { activeOperations, backups, progressFor, invalidate } = useServerBackupsQueue(
computed(() => ctx.serverId),
@@ -311,6 +313,7 @@ async function onBackupDismiss(item: BackupAdmonitionEntry) {
}
async function onBackupCancel(item: BackupAdmonitionEntry) {
if (!canManageBackups.value) return
if (cancellingIds.has(item.key)) return
cancellingIds.add(item.key)
try {
@@ -337,6 +340,7 @@ async function onBackupCancel(item: BackupAdmonitionEntry) {
}
async function onBackupRetry(item: BackupAdmonitionEntry) {
if (!canManageBackups.value) return
await client.archon.backups_queue_v1.retry(ctx.serverId, ctx.worldId.value!, item.backupId)
dismissedIds.add(item.key)
await invalidate()
@@ -402,6 +406,8 @@ function onContentErrorDismiss() {
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
:retry-disabled="!canSetup"
:retry-disabled-tooltip="permissionDeniedMessage"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
@@ -422,6 +428,8 @@ function onContentErrorDismiss() {
:item="item.entry"
:dismissible="dismissible"
:cancelling="cancellingIds.has(item.entry.key)"
:can-manage-backups="canManageBackups"
:permission-denied-message="permissionDeniedMessage"
@dismiss="onBackupDismiss(item.entry)"
@cancel="onBackupCancel(item.entry)"
@retry="onBackupRetry(item.entry)"
@@ -53,7 +53,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
<button
v-tooltip="createDisabledTooltip"
:disabled="createDisabled"
@click="createBackup"
>
<PlusIcon />
Create backup
</button>
@@ -69,23 +73,35 @@ import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const props = withDefaults(
defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
canCreate?: boolean
permissionDeniedMessage?: string
}>(),
{
backups: undefined,
canCreate: true,
permissionDeniedMessage: undefined,
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
@@ -109,6 +125,14 @@ const nameExists = computed(() => {
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const createDisabled = computed(
() => createMutation.isPending.value || nameExists.value || !props.canCreate,
)
const createDisabledTooltip = computed(() =>
props.canCreate
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
const focusInput = () => {
nextTick(() => {
@@ -129,6 +153,7 @@ const hideModal = () => {
}
const createBackup = () => {
if (!props.canCreate) return
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
isRateLimited.value = false
@@ -67,7 +67,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirmDelete">
<button
v-tooltip="deleteDisabledTooltip"
:disabled="!props.canDelete"
@click="confirmDelete"
>
<TrashIcon />
{{ formatMessage(messages.confirm, { count }) }}
</button>
@@ -92,6 +96,17 @@ import BackupItem from './BackupItem.vue'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
canDelete?: boolean
permissionDeniedMessage?: string
}>(),
{
canDelete: true,
permissionDeniedMessage: undefined,
},
)
const emit = defineEmits<{
(e: 'delete', backup: Archon.BackupsQueue.v1.BackupQueueBackup | undefined): void
(e: 'bulk-delete', backups: Archon.BackupsQueue.v1.BackupQueueBackup[]): void
@@ -133,6 +148,11 @@ const count = computed(() => (isBulk.value ? bulkBackups.value.length : 1))
const displayBackups = computed(() =>
isBulk.value ? bulkBackups.value : singleBackup.value ? [singleBackup.value] : [],
)
const deleteDisabledTooltip = computed(() =>
props.canDelete
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
singleBackup.value = backup
@@ -149,6 +169,7 @@ function showBulk(backups: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
}
function confirmDelete() {
if (!props.canDelete) return
modal.value?.hide()
if (isBulk.value) {
emit('bulk-delete', bulkBackups.value)
@@ -38,7 +38,10 @@ const props = withDefaults(
showCopyIdAction?: boolean
showDebugInfo?: boolean
restoreDisabled?: string
writeDisabled?: boolean
writeDisabledTooltip?: string
selected?: boolean
highlighted?: boolean
}>(),
{
preview: false,
@@ -47,7 +50,10 @@ const props = withDefaults(
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
writeDisabled: false,
writeDisabledTooltip: undefined,
selected: false,
highlighted: false,
},
)
@@ -60,6 +66,12 @@ const backupIcon = computed(() => {
return UserRoundIcon
})
const itemBorderClass = computed(() => {
if (props.selected) return 'border-brand-green'
if (props.highlighted) return 'border-purple backup-item-highlighted'
return 'border-transparent'
})
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
@@ -81,13 +93,20 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
disabled: !props.kyrosUrl || !props.jwt,
})
options.push({ id: 'rename', action: () => emit('rename') })
options.push({
id: 'rename',
action: () => emit('rename'),
disabled: props.writeDisabled,
tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
})
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: props.writeDisabled,
tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
})
return options
@@ -123,7 +142,7 @@ const messages = defineMessages({
<template>
<div
class="flex items-center gap-4 rounded-[20px] border border-solid bg-surface-3 p-4 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.3),0px_1px_3px_0px_rgba(0,0,0,0.15)]"
:class="props.selected ? 'border-brand-green' : 'border-transparent'"
:class="itemBorderClass"
>
<div class="flex min-w-0 flex-1 items-center gap-4">
<!-- Icon tile -->
@@ -215,3 +234,25 @@ const messages = defineMessages({
}}</pre>
</div>
</template>
<style scoped>
@keyframes backup-item-highlight-pulse {
0%,
100% {
box-shadow:
0 0 0 0 var(--color-purple-highlight),
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 0px rgba(0, 0, 0, 0.15);
}
50% {
box-shadow:
0 0 0 3px var(--color-purple-highlight),
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 0px rgba(0, 0, 0, 0.15);
}
}
.backup-item-highlighted {
animation: backup-item-highlight-pulse 1.25s ease-in-out infinite;
}
</style>
@@ -29,7 +29,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
<button
v-tooltip="renameDisabledTooltip"
:disabled="renameDisabled"
@click="renameBackup"
>
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
@@ -51,23 +55,35 @@ import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const props = withDefaults(
defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
canRename?: boolean
permissionDeniedMessage?: string
}>(),
{
backups: undefined,
canRename: true,
permissionDeniedMessage: undefined,
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
@@ -99,6 +115,14 @@ const nameExists = computed(() => {
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const renameDisabled = computed(
() => renameMutation.isPending.value || nameExists.value || !props.canRename,
)
const renameDisabledTooltip = computed(() =>
props.canRename
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
const backupNumber = computed(
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
@@ -124,6 +148,7 @@ function hide() {
}
const renameBackup = () => {
if (!props.canRename) return
if (!currentBackup.value) {
addNotification({
type: 'error',
@@ -24,7 +24,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="isRestoring || ctx.isServerRunning.value" @click="restoreBackup">
<button
v-tooltip="restoreDisabledTooltip"
:disabled="restoreDisabled"
@click="restoreBackup"
>
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
<RotateCounterClockwiseIcon v-else />
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
@@ -39,23 +43,37 @@
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = withDefaults(
defineProps<{
canRestore?: boolean
permissionDeniedMessage?: string
}>(),
{
canRestore: true,
permissionDeniedMessage: undefined,
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
function safetyBackupName(backupName: string) {
@@ -72,6 +90,14 @@ const restoreMutation = useMutation({
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
const isRestoring = ref(false)
const restoreDisabled = computed(
() => isRestoring.value || ctx.isServerRunning.value || !props.canRestore,
)
const restoreDisabledTooltip = computed(() =>
props.canRestore
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup
@@ -79,7 +105,7 @@ function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
}
const restoreBackup = () => {
if (!currentBackup.value || isRestoring.value) {
if (!props.canRestore || !currentBackup.value || isRestoring.value) {
if (!currentBackup.value) {
addNotification({
type: 'error',
@@ -3,17 +3,21 @@
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="'Edit icon'"
v-tooltip="editIconTooltip"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:disabled="isIconActionLoading"
:disabled="isIconActionDisabled"
:options="[
{
id: 'upload',
action: () => triggerFileInput(),
disabled: !props.canEdit,
tooltip: !props.canEdit ? editIconTooltip : undefined,
},
{
id: 'sync',
action: () => resetIcon(),
disabled: !props.canEdit,
tooltip: !props.canEdit ? editIconTooltip : undefined,
},
]"
>
@@ -47,19 +51,39 @@ import { computed, ref } from 'vue'
import { OverflowMenu, ServerIcon } from '#ui/components'
import { useServerImage } from '#ui/composables'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const props = withDefaults(
defineProps<{
canEdit?: boolean
permissionDeniedMessage?: string
}>(),
{
canEdit: true,
permissionDeniedMessage: undefined,
},
)
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server } = injectModrinthServerContext()
const queryClient = useQueryClient()
const isUploadingIcon = ref(false)
const isSyncingIcon = ref(false)
const isIconActionLoading = computed(() => isUploadingIcon.value || isSyncingIcon.value)
const isIconActionDisabled = computed(() => isIconActionLoading.value || !props.canEdit)
const editIconTooltip = computed(() =>
props.canEdit
? 'Edit icon'
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
const {
image: displayIcon,
@@ -84,7 +108,7 @@ function isNotFound(error: unknown): boolean {
}
const uploadFile = async (e: Event) => {
if (isIconActionLoading.value) return
if (isIconActionDisabled.value) return
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) {
@@ -194,7 +218,7 @@ const uploadFile = async (e: Event) => {
}
const resetIcon = async () => {
if (isIconActionLoading.value) return
if (isIconActionDisabled.value) return
isSyncingIcon.value = true
try {
@@ -234,7 +258,7 @@ const resetIcon = async () => {
}
const triggerFileInput = () => {
if (isIconActionLoading.value) return
if (isIconActionDisabled.value) return
const input = document.createElement('input')
input.type = 'file'
@@ -224,9 +224,10 @@
<script setup lang="ts">
import { LoaderIcon } from '@modrinth/assets'
import type { Loaders } from '@modrinth/utils'
import type { ServerLoader } from '#ui/utils/loaders'
defineProps<{
loader: Loaders
loader: ServerLoader
}>()
</script>
@@ -1,3 +1,4 @@
export * from './access'
export * from './admonitions'
export * from './backups'
export * from './flows'
@@ -38,6 +38,7 @@
import { computed } from 'vue'
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
import type { ServerLoader } from '#ui/utils/loaders'
import AutoLink from '../../base/AutoLink.vue'
import LoaderIcon from '../icons/LoaderIcon.vue'
@@ -45,7 +46,7 @@ import Separator from './Separator.vue'
defineProps<{
noSeparator?: boolean
loader?: 'Fabric' | 'Quilt' | 'Forge' | 'NeoForge' | 'Paper' | 'Spigot' | 'Bukkit' | 'Vanilla'
loader?: ServerLoader
loaderVersion?: string
isLink?: boolean
}>()
@@ -36,13 +36,29 @@
</div>
<Avatar v-else src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="z-10" />
<div class="z-10 ml-4 flex min-w-0 flex-col gap-1.5">
<div class="flex flex-row items-center gap-2">
<div class="flex flex-row items-center gap-2.5">
<h2
class="m-0 truncate text-xl font-bold text-contrast"
:class="{ 'opacity-50': isDisabled }"
>
{{ name }}
</h2>
<div
v-if="owner"
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
class="flex min-w-0 items-center gap-1.5 rounded-full bg-surface-4/80 px-2 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
:class="{ 'opacity-50': isDisabled }"
>
<Avatar
:src="owner.avatarUrl"
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
:tint-by="owner.username"
size="1.25rem"
circle
no-shadow
/>
<span class="max-w-32 truncate">{{ owner.username }}</span>
</div>
<span class="truncate" :class="{ 'opacity-50': isDisabled }">
<IntlFormatted
@@ -158,6 +174,7 @@ import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import CopyCode from '../../base/CopyCode.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
import type { ServerListingOwner } from '../access/types'
import ServerInfoLabels from '../labels/ServerInfoLabels.vue'
import MedalBackgroundImage from './MedalBackgroundImage.vue'
@@ -176,6 +193,7 @@ type MedalServerListingProps = {
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
medal_expires?: string
owner?: ServerListingOwner
}
const props = defineProps<MedalServerListingProps>()
@@ -222,6 +240,14 @@ const messages = defineMessages({
id: 'servers.medal-listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
ownerTooltip: {
id: 'servers.medal-listing.owner-tooltip',
defaultMessage: 'Owned by {username}',
},
ownerAvatarAlt: {
id: 'servers.medal-listing.owner-avatar-alt',
defaultMessage: "{username}'s avatar",
},
newServerLabel: {
id: 'servers.medal-listing.new-server-label',
defaultMessage: 'New server',
@@ -21,6 +21,8 @@
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
:primary-tooltip="busyTooltip"
:dropdown-tooltip="busyTooltip"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
@@ -37,6 +39,7 @@
:primary-disabled="true"
:dropdown-disabled="!canKill"
:primary-muted="true"
:dropdown-tooltip="busyTooltip"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
@@ -1,6 +1,7 @@
import { computed, type Ref } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -15,6 +16,7 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(
() =>
@@ -34,20 +36,27 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const showStopSplit = computed(() => isRunning.value || isStarting.value || isStopping.value)
const showRestartButton = computed(() => isRunning.value || isStarting.value)
const isBlockedByPropsOrBusy = computed(
() => Boolean(options?.disabled?.value) || busyReasons.value.length > 0,
const isBlockedByPropsBusyOrPermission = computed(
() =>
!canUsePowerActions.value ||
Boolean(options?.disabled?.value) ||
busyReasons.value.length > 0,
)
const busyTooltip = computed(() => {
if (!canUsePowerActions.value) return permissionDeniedMessage.value
if (isStarting.value) return 'Your server is starting'
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
const canTakeAction = computed(() => !isTransitioning.value && !isBlockedByPropsOrBusy.value)
const canTakeAction = computed(
() => !isTransitioning.value && !isBlockedByPropsBusyOrPermission.value,
)
const canKill = computed(
() =>
!isBlockedByPropsOrBusy.value && (isStopping.value || isRunning.value || isStarting.value),
!isBlockedByPropsBusyOrPermission.value &&
(isStopping.value || isRunning.value || isStarting.value),
)
const primaryActionText = computed(() => {
+16 -9
View File
@@ -3,9 +3,9 @@ import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 5 })
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 15 })
export function useRelativeTime() {
export function useRelativeTime(options?: Intl.RelativeTimeFormatOptions) {
const { locale } = injectI18n()
return (value: Date | number | string | null | undefined) => {
@@ -29,7 +29,7 @@ export function useRelativeTime() {
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
const rtf = getFormatter(locale.value)
const rtf = getFormatter(locale.value, options)
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
@@ -49,15 +49,22 @@ export function useRelativeTime() {
}
}
function getFormatter(locale: string): Intl.RelativeTimeFormat {
let formatter = formatterCache.get(locale)
function getFormatter(
locale: string,
options?: Intl.RelativeTimeFormatOptions,
): Intl.RelativeTimeFormat {
const localeDefinition = LOCALES.find((loc) => loc.code === locale)
const numeric = options?.numeric ?? localeDefinition?.numeric ?? 'auto'
const style = options?.style ?? 'long'
const cacheKey = `${locale}:${numeric}:${style}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
const localeDefinition = LOCALES.find((loc) => loc.code === locale)
formatter = new Intl.RelativeTimeFormat(locale, {
numeric: localeDefinition?.numeric || 'auto',
style: 'long',
...options,
numeric,
style,
})
formatterCache.set(locale, formatter)
formatterCache.set(cacheKey, formatter)
}
return formatter
}
+1
View File
@@ -14,6 +14,7 @@ export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-manage-core-runtime'
export * from './server-permissions'
export * from './sticky-observer'
export * from './terminal'
export * from './use-loading-bar-token'
@@ -23,7 +23,7 @@ export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string
enabled: computed(() => !!worldId.value),
refetchInterval: (q) => {
const data = q.state.data as Archon.BackupsQueue.v1.BackupsQueueResponse | undefined
return data?.active_operations?.length ? 3000 : false
return data?.active_operations?.length ? 30_000 : false
},
})
@@ -4,13 +4,12 @@ import {
setNodeAuthState,
type UploadState,
} from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason, CancelUploadHandler } from '../providers/server-context'
import type { BusyReason, CancelUploadHandler, ServerStats } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
@@ -26,6 +25,7 @@ type UseServerManageCoreRuntimeOptions = {
serverId: ReadableRef<string>
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
isSyncingContent: ReadableRef<boolean>
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
@@ -35,7 +35,7 @@ type UseServerManageCoreRuntimeOptions = {
onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void
}
const createInitialStats = (): Stats => ({
const createInitialStats = (): ServerStats => ({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
@@ -91,7 +91,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
const isServerRunning = computed(() => serverPowerState.value === 'running')
const stats = ref<Stats>(createInitialStats())
const stats = ref<ServerStats>(createInitialStats())
const uptimeSeconds = ref(0)
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
@@ -141,7 +141,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}, 1000)
}
const updateStats = (currentStats: Stats['current']) => {
const updateStats = (currentStats: ServerStats['current']) => {
if (!shouldProcessEvent()) return
if (!isConnected.value) isConnected.value = true
cpuData.value = appendGraphData(cpuData.value, currentStats.cpu_percent)
@@ -384,6 +384,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
fsAuth.value = await client.archon.servers_v0.getFilesystemAuth(options.serverId.value)
}
const currentUserPermissions = computed(() => options.server.value?.current_user_permissions ?? 0)
const serverFull = computed(() => options.serverFull?.value ?? null)
provideModrinthServerContext({
get serverId() {
@@ -391,6 +393,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
},
worldId: options.worldId as Ref<string | null>,
server: options.server as Ref<Archon.Servers.v0.Server>,
serverFull,
currentUserPermissions,
isConnected,
isWsAuthIncorrect,
powerState: serverPowerState,
@@ -0,0 +1,345 @@
import type { Archon } from '@modrinth/api-client'
import { useQueryClient } from '@tanstack/vue-query'
import type { ComputedRef, Ref } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
import { injectModrinthClient } from '#ui/providers'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SyncUnsubscriber = () => void
type UseServerPanelSyncOptions = {
serverId: ReadableRef<string>
worldId: ReadableRef<string | null>
}
const ACTION_LOG_INVALIDATE_DELAY_MS = 500
export function useServerPanelSync(options: UseServerPanelSyncOptions) {
const client = injectModrinthClient()
const queryClient = useQueryClient()
let activeServerId: string | null = null
let unsubscribers: SyncUnsubscriber[] = []
let mounted = false
let actionLogInvalidateTimer: ReturnType<typeof setTimeout> | null = null
const legacyServerDetailKey = (serverId: string) => ['servers', 'detail', serverId] as const
const serverV1DetailKey = (serverId: string) => ['servers', 'v1', 'detail', serverId] as const
const contentListKey = (serverId: string) => ['content', 'list', 'v1', serverId] as const
const actionLogBaseKey = (serverId: string) =>
['servers', 'action-log', 'v1', 'infinite', serverId] as const
function connect(targetServerId: string) {
if (!targetServerId || activeServerId === targetServerId) return
disconnect()
activeServerId = targetServerId
if (!client.archon.sync.getStatus(targetServerId)?.lastEventId) {
void invalidateCorePanelQueries(targetServerId)
}
unsubscribers = [
client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)),
]
void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => {
console.warn(
`[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`,
error,
)
})
}
function disconnect() {
if (actionLogInvalidateTimer) {
clearTimeout(actionLogInvalidateTimer)
actionLogInvalidateTimer = null
}
for (const unsubscribe of unsubscribers) unsubscribe()
unsubscribers = []
if (activeServerId) {
client.archon.sync.disconnect(activeServerId)
activeServerId = null
}
}
function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) {
if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') {
void invalidateCorePanelQueries(serverId)
return
}
if (event.type === 'protocol.error') {
console.warn(`[server-panel-sync] Protocol error for ${serverId}: ${event.error}`)
return
}
scheduleActionLogInvalidation(serverId)
if (event.type.startsWith('backup.')) {
handleBackupEvent(serverId)
return
}
switch (event.type) {
case 'server.patch':
handleServerPatch(serverId, event)
break
case 'server.network.patch':
handleServerNetworkPatch(serverId, event)
break
case 'server.transfer.start':
case 'server.transfer.done':
void invalidateServerDetails(serverId)
break
case 'users.patch':
handleUsersPatch(serverId)
break
case 'world.patch':
handleWorldPatch(serverId, event)
break
case 'world.startup.patch':
handleWorldStartupPatch(serverId, event)
break
case 'world.content.addon.patch':
handleWorldContentAddonPatch(serverId, event)
break
case 'world.content.base.update':
handleWorldContentBaseUpdate(serverId, event)
break
}
}
function handleServerPatch(serverId: string, event: Archon.Sync.v1.ServerPatchEvent) {
queryClient.setQueryData<Archon.Servers.v0.Server>(
legacyServerDetailKey(serverId),
(current) =>
current
? {
...current,
name: event.name,
net: {
...current.net,
domain: event.subdomain,
},
}
: current,
)
queryClient.setQueryData<Archon.Servers.v1.ServerFull>(
serverV1DetailKey(serverId),
(current) =>
current
? {
...current,
name: event.name,
subdomain: event.subdomain,
}
: current,
)
}
function handleServerNetworkPatch(
serverId: string,
event: Archon.Sync.v1.ServerNetworkPatchEvent,
) {
queryClient.setQueryData<Archon.Servers.v0.Allocation[]>(
['servers', 'allocations', serverId],
event.ports,
)
void invalidateServerDetails(serverId)
}
function handleUsersPatch(serverId: string) {
void queryClient.invalidateQueries({ queryKey: ['servers', 'users', 'v1', serverId] })
void invalidateServerDetails(serverId)
}
function handleWorldPatch(serverId: string, event: Archon.Sync.v1.WorldPatchEvent) {
patchServerFullWorld(serverId, event.world_id, (world) => ({
...world,
name: event.name,
}))
}
function handleWorldStartupPatch(serverId: string, event: Archon.Sync.v1.WorldStartupPatchEvent) {
patchServerFullWorld(serverId, event.world_id, (world) =>
world.content
? {
...world,
content: {
...world.content,
java_version: event.java_version,
invocation: event.invocation,
original_invocation: event.original_invocation,
},
}
: world,
)
void queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] })
}
function handleWorldContentAddonPatch(
serverId: string,
event: Archon.Sync.v1.WorldContentAddonPatchEvent,
) {
if (event.world_id !== options.worldId.value) {
void invalidateContentAndServerDetails(serverId)
return
}
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListKey(serverId), (current) =>
current
? {
...current,
addons: mergeAddonSpecs(current.addons ?? [], event.specs),
}
: current,
)
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
}
function handleWorldContentBaseUpdate(
serverId: string,
event: Archon.Sync.v1.WorldContentBaseUpdateEvent,
) {
if (event.world_id === options.worldId.value) {
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListKey(serverId), (current) =>
current ? { ...current, ...event.spec } : event.spec,
)
} else {
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
}
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
}
function handleBackupEvent(serverId: string) {
void queryClient.invalidateQueries({ queryKey: ['backups', 'queue', serverId] })
void invalidateServerDetails(serverId)
}
function patchServerFullWorld(
serverId: string,
worldId: string,
patch: (world: Archon.Servers.v1.WorldFull) => Archon.Servers.v1.WorldFull,
) {
queryClient.setQueryData<Archon.Servers.v1.ServerFull>(
serverV1DetailKey(serverId),
(current) =>
current
? {
...current,
worlds: current.worlds.map((world) => (world.id === worldId ? patch(world) : world)),
}
: current,
)
}
function scheduleActionLogInvalidation(serverId: string) {
if (actionLogInvalidateTimer) clearTimeout(actionLogInvalidateTimer)
actionLogInvalidateTimer = setTimeout(() => {
actionLogInvalidateTimer = null
void queryClient.invalidateQueries({ queryKey: actionLogBaseKey(serverId) })
}, ACTION_LOG_INVALIDATE_DELAY_MS)
}
async function invalidateServerDetails(serverId: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: legacyServerDetailKey(serverId) }),
queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) }),
])
}
async function invalidateContentAndServerDetails(serverId: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: contentListKey(serverId) }),
invalidateServerDetails(serverId),
])
}
async function invalidateCorePanelQueries(serverId: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: legacyServerDetailKey(serverId) }),
queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) }),
queryClient.invalidateQueries({ queryKey: contentListKey(serverId) }),
queryClient.invalidateQueries({ queryKey: ['backups', 'queue', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'users', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: actionLogBaseKey(serverId) }),
queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] }),
])
}
function mergeAddonSpecs(
currentAddons: Archon.Content.v1.Addon[],
incomingAddons: Archon.Content.v1.Addon[],
): Archon.Content.v1.Addon[] {
const currentByFilename = new Map(
currentAddons.map((addon) => [normalizeAddonFilename(addon.filename), addon] as const),
)
return incomingAddons.map((incoming) =>
mergeAddonSpec(currentByFilename.get(normalizeAddonFilename(incoming.filename)), incoming),
)
}
function mergeAddonSpec(
current: Archon.Content.v1.Addon | undefined,
incoming: Archon.Content.v1.Addon,
): Archon.Content.v1.Addon {
if (!current) return incoming
return {
...current,
...incoming,
filesize: incoming.filesize || current.filesize,
name: incoming.name ?? current.name,
owner: incoming.owner ?? current.owner,
icon_url: incoming.icon_url ?? current.icon_url,
has_update: incoming.has_update ?? current.has_update,
project_id: incoming.project_id ?? current.project_id,
version: incoming.version
? {
...incoming.version,
name: incoming.version.name ?? current.version?.name ?? null,
environment: incoming.version.environment ?? current.version?.environment ?? null,
}
: current.version,
}
}
function normalizeAddonFilename(filename: string): string {
return filename.endsWith('.disabled') ? filename.slice(0, -'.disabled'.length) : filename
}
onMounted(() => {
mounted = true
connect(options.serverId.value)
})
watch(
() => options.serverId.value,
(serverId) => {
if (!mounted) return
if (serverId) {
connect(serverId)
} else {
disconnect()
}
},
)
onUnmounted(() => {
mounted = false
disconnect()
})
return {
disconnect,
}
}
@@ -0,0 +1,118 @@
import type { Archon } from '@modrinth/api-client'
import { computed } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
export type ServerPermissionName = keyof typeof Archon.ServerUsers.v1.UserScope
type ServerPermissionValue = Archon.Servers.v0.UserScope | Archon.ServerUsers.v1.UserScope
const U64_SIZE = 64n
const U64_MODULUS = 1n << U64_SIZE
export const serverPermissionBits = {
NONE: 0n,
BASE_READ: 1n << 63n,
POWER_ACTIONS: 1n << 62n,
EXEC_COMMANDS: 1n << 61n,
FILES_WRITE: 1n << 60n,
SETUP: 1n << 59n,
BACKUPS: 1n << 58n,
ADVANCED: 1n << 57n,
RESET_SERVER: 1n << 56n,
MANAGE_USERS: 1n << 55n,
SUPPORT_AGENT: 1n,
INFRA_MANAGER: 1n << 1n,
INFRA_MANAGER_READ: 1n << 2n,
INFRA_SERVERS_XFER: 1n << 3n,
INFRA_USERS: 1n << 4n,
SERVER_ADMIN: ((1n << 64n) - 1n) ^ ((1n << 15n) - 1n),
} as const satisfies Record<ServerPermissionName, bigint>
function parsePermissionNumber(value: number) {
const bigintValue = BigInt(value)
return bigintValue < 0n ? bigintValue + U64_MODULUS : bigintValue
}
function parsePermissionString(value: string) {
const numericValue = Number(value)
if (value.trim() !== '' && Number.isFinite(numericValue)) {
return parsePermissionNumber(numericValue)
}
const permissions = value
.split('|')
.map((permission) => permission.trim())
.filter((permission): permission is ServerPermissionName => permission in serverPermissionBits)
if (permissions.length === 0) return 0n
return permissions.reduce((mask, permission) => mask | serverPermissionBits[permission], 0n)
}
function parsePermissions(permissions: ServerPermissionValue) {
return typeof permissions === 'number'
? parsePermissionNumber(permissions)
: parsePermissionString(permissions)
}
function hasPermissionBit(permissions: ServerPermissionValue, scope: ServerPermissionName) {
const permission = serverPermissionBits[scope]
if (permission === 0n) return true
const permissionsMask = parsePermissions(permissions)
return (permissionsMask & permission) === permission
}
export function hasServerPermission(
permissions: ServerPermissionValue,
scope: ServerPermissionName,
) {
if (
scope !== 'NONE' &&
scope !== 'SERVER_ADMIN' &&
hasPermissionBit(permissions, 'SERVER_ADMIN')
) {
return true
}
return hasPermissionBit(permissions, scope)
}
export function useServerPermissions() {
const { formatMessage } = useVIntl()
const { currentUserPermissions } = injectModrinthServerContext()
const hasCurrentUserPermission = (scope: ServerPermissionName) =>
hasServerPermission(currentUserPermissions.value, scope)
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
const canUsePowerActions = computed(() => hasCurrentUserPermission('POWER_ACTIONS'))
const canExecuteCommands = computed(() => hasCurrentUserPermission('EXEC_COMMANDS'))
const canWriteFiles = computed(() => hasCurrentUserPermission('FILES_WRITE'))
const canSetup = computed(() => hasCurrentUserPermission('SETUP'))
const canManageBackups = computed(() => hasCurrentUserPermission('BACKUPS'))
const canUseAdvancedSettings = computed(() => hasCurrentUserPermission('ADVANCED'))
const canResetServer = computed(() => hasCurrentUserPermission('RESET_SERVER'))
const canManageUsers = computed(() => hasCurrentUserPermission('MANAGE_USERS'))
const permissionTooltip = (allowed: boolean) =>
allowed ? undefined : permissionDeniedMessage.value
return {
currentUserPermissions,
permissionDeniedMessage,
hasCurrentUserPermission,
canUsePowerActions,
canExecuteCommands,
canWriteFiles,
canSetup,
canManageBackups,
canUseAdvancedSettings,
canResetServer,
canManageUsers,
permissionTooltip,
}
}
@@ -1,7 +1,11 @@
<template>
<div class="flex items-center gap-1">
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
<button @click="emit('clear')">
<button
v-tooltip="clearDisabled ? clearDisabledTooltip : undefined"
:disabled="clearDisabled"
@click="emit('clear')"
>
<XIcon />
Clear
</button>
@@ -56,6 +60,8 @@ defineProps<{
shareDisabledTooltip?: string
sharing?: boolean
fullscreen?: boolean
clearDisabled?: boolean
clearDisabledTooltip?: string
showDelete?: boolean
deleteDisabled?: boolean
deleteDisabledTooltip?: string
@@ -44,6 +44,8 @@
:share-disabled="resolvedShareDisabled"
:sharing="isSharing"
:fullscreen="isFullscreen"
:clear-disabled="resolvedClearDisabled"
:clear-disabled-tooltip="resolvedClearDisabledTooltip"
:show-delete="showDelete"
:delete-disabled="resolvedDeleteDisabled"
:delete-disabled-tooltip="ctx.deleteDisabledTooltip"
@@ -59,6 +61,8 @@
class="min-h-0 flex-1"
:show-input="resolvedShowInput"
:disable-input="resolvedInputDisabled"
:disable-input-tooltip="resolvedInputDisabledTooltip"
:disabled-input-placeholder="resolvedInputDisabledPlaceholder"
:fullscreen="isFullscreen"
:empty-state-type="ctx.emptyStateType"
:loading="resolvedLoading"
@@ -217,6 +221,11 @@ const resolvedDisableInput = computed(() => {
return isRef(v) ? v.value : v
})
function unwrapMaybeRef<T>(value: T | { value: T } | undefined): T | undefined {
if (value === undefined) return undefined
return isRef(value) ? value.value : value
}
// needs historical log start/end flags on ws to be properly useful
const resolvedLoading = computed(() => {
const v = ctx.loading
@@ -226,6 +235,14 @@ const resolvedLoading = computed(() => {
const resolvedInputDisabled = computed(() => resolvedDisableInput.value || resolvedLoading.value)
const resolvedInputDisabledTooltip = computed(() =>
resolvedDisableInput.value ? unwrapMaybeRef(ctx.disableCommandInputTooltip) : undefined,
)
const resolvedInputDisabledPlaceholder = computed(() =>
resolvedInputDisabledTooltip.value ? 'Command input disabled' : 'Server is not running',
)
const resolvedShareDisabled = computed(() => {
const v = ctx.shareDisabled
if (!v) return false
@@ -240,6 +257,16 @@ const resolvedDeleteDisabled = computed(() => {
return isRef(v) ? v.value : v
})
const resolvedClearDisabled = computed(() => {
const v = ctx.clearDisabled
if (!v) return false
return isRef(v) ? v.value : v
})
const resolvedClearDisabledTooltip = computed(() =>
resolvedClearDisabled.value ? unwrapMaybeRef(ctx.clearDisabledTooltip) : undefined,
)
function handleTerminalReady(_terminal: Terminal) {
rewriteFiltered()
}
@@ -360,10 +387,12 @@ watch(resolvedLoading, (loading) => {
})
function handleCommand(cmd: string) {
if (resolvedInputDisabled.value) return
ctx.sendCommand?.(cmd)
}
function handleClear() {
if (resolvedClearDisabled.value) return
const term = terminalRef.value?.terminal
if (term) clearSearchHighlights(term)
terminalRef.value?.reset()
@@ -14,10 +14,13 @@ export interface ConsoleManagerContext {
sendCommand?: (cmd: string) => void
showCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
disableCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
disableCommandInputTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
loading?: Ref<boolean> | ComputedRef<boolean>
onClear?: () => void
clearDisabled?: Ref<boolean> | ComputedRef<boolean>
clearDisabledTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
onDelete?: () => Promise<void>
deleteDisabled?: Ref<boolean> | ComputedRef<boolean>
deleteDisabledTooltip?: string
@@ -55,6 +55,9 @@ interface Props {
hideSwitchVersion?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
disabledTooltip?: string | null
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
@@ -73,6 +76,9 @@ const props = withDefaults(defineProps<Props>(), {
hideSwitchVersion: false,
overflowOptions: undefined,
disabled: false,
disabledTooltip: undefined,
toggleDisabled: false,
toggleDisabledTooltip: undefined,
showCheckbox: false,
hideDelete: false,
hideActions: false,
@@ -98,6 +104,7 @@ const versionNumberRef = ref<HTMLElement | null>(null)
const fileNameRef = ref<HTMLElement | null>(null)
const isDisabled = computed(() => props.disabled || props.installing)
const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
const clientWarningMessage = computed(() => {
switch (props.clientWarning) {
@@ -173,8 +180,19 @@ const deleteHovered = ref(false)
>
{{ project.title }}
</AutoLink>
<Tooltip v-if="isClientOnly">
<TriangleAlertIcon class="size-4 shrink-0 text-orange" />
<Tooltip
v-if="isClientOnly"
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
>
<span
class="inline-flex size-5 shrink-0 cursor-help items-center justify-center"
tabindex="0"
>
<TriangleAlertIcon class="pointer-events-none size-4 text-orange" />
</span>
<template #popper>
<div class="max-w-[18rem] text-sm">
{{ formatMessage(clientWarningMessage) }}
@@ -283,7 +301,11 @@ const deleteHovered = ref(false)
hover-color-fill="background"
>
<button
v-tooltip="formatMessage(commonMessages.updateAvailableLabel)"
v-tooltip="
isDisabled && disabledTooltip
? disabledTooltip
: formatMessage(commonMessages.updateAvailableLabel)
"
:disabled="isDisabled"
@click="emit('update')"
>
@@ -296,7 +318,11 @@ const deleteHovered = ref(false)
type="transparent"
>
<button
v-tooltip="formatMessage(commonMessages.switchVersionButton)"
v-tooltip="
isDisabled && disabledTooltip
? disabledTooltip
: formatMessage(commonMessages.switchVersionButton)
"
:disabled="isDisabled"
@click="emit('switchVersion')"
>
@@ -307,8 +333,13 @@ const deleteHovered = ref(false)
<Toggle
v-if="enabled !== undefined"
v-tooltip="
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
? (toggleDisabledTooltip ?? disabledTooltip)
: undefined
"
:model-value="enabled"
:disabled="isDisabled"
:disabled="isToggleDisabled"
:aria-label="project.title"
class="my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
@@ -317,11 +348,13 @@ const deleteHovered = ref(false)
<ButtonStyled v-if="hasDeleteListener && !props.hideDelete" circular type="transparent">
<button
v-tooltip="
formatMessage(
shiftHeld && deleteHovered
? commonMessages.deleteImmediatelyLabel
: commonMessages.deleteLabel,
)
isDisabled && disabledTooltip
? disabledTooltip
: formatMessage(
shiftHeld && deleteHovered
? commonMessages.deleteImmediatelyLabel
: commonMessages.deleteLabel,
)
"
:disabled="isDisabled"
@click="emit('delete', $event)"
@@ -281,6 +281,9 @@ function handleSort(column: ContentCardTableSortColumn) {
:hide-switch-version="item.hideSwitchVersion"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
@@ -336,6 +339,9 @@ function handleSort(column: ContentCardTableSortColumn) {
:client-warning="item.clientWarning"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
@@ -224,6 +224,7 @@ onUnmounted(() => {
<Tooltip
v-if="hasContentListener"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && isExpanded"
:auto-hide="false"
@@ -293,6 +294,7 @@ onUnmounted(() => {
<Tooltip
v-if="collapsedOptions.length"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && !isExpanded"
:auto-hide="false"
@@ -68,6 +68,7 @@ interface Props {
selectedItems: ContentItem[]
contentTypeLabel?: string
isBusy?: boolean
busyTooltip?: string | null
isBulkOperating?: boolean
bulkOperation?: BulkOperationType | null
bulkProgress?: number
@@ -80,6 +81,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
contentTypeLabel: undefined,
isBusy: false,
busyTooltip: undefined,
isBulkOperating: false,
bulkOperation: null,
bulkProgress: 0,
@@ -196,9 +198,11 @@ const bulkProgressMessage = computed(() => {
<ButtonStyled type="transparent">
<button
v-tooltip="
allEnabled
? formatMessage(messages.allAlreadyEnabled)
: formatMessage(commonMessages.enableButton)
isBusy && busyTooltip
? busyTooltip
: allEnabled
? formatMessage(messages.allAlreadyEnabled)
: formatMessage(commonMessages.enableButton)
"
:disabled="isBusy || allEnabled"
@click="emit('enable')"
@@ -210,9 +214,11 @@ const bulkProgressMessage = computed(() => {
<ButtonStyled type="transparent">
<button
v-tooltip="
allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(commonMessages.disableButton)
isBusy && busyTooltip
? busyTooltip
: allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(commonMessages.disableButton)
"
:disabled="isBusy || allDisabled"
@click="emit('disable')"
@@ -8,11 +8,13 @@
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { count }) }}
{{ formatMessage(messages.admonitionBody, { count: props.count }) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
:backup-name="
props.backupTip ? `Before bulk update (${props.backupTip})` : 'Before bulk update'
"
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
@update:buttons-disabled="buttonsDisabled = $event"
/>
@@ -27,9 +29,13 @@
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="confirm">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<DownloadIcon />
{{ formatMessage(messages.updateButton, { count }) }}
{{ formatMessage(messages.updateButton, { count: props.count }) }}
</button>
</ButtonStyled>
</div>
@@ -76,10 +82,12 @@ const messages = defineMessages({
},
})
defineProps<{
const props = defineProps<{
count: number
server?: boolean
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -95,6 +103,7 @@ function show() {
}
function confirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('update')
}
@@ -3,23 +3,23 @@
ref="modal"
:header="
formatMessage(messages.header, {
itemType: formatContentTypeSentence(formatMessage, itemType, count),
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
})
"
:fade="variant === 'server' ? 'warning' : 'danger'"
:fade="props.variant === 'server' ? 'warning' : 'danger'"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition
:type="variant === 'server' ? 'warning' : 'critical'"
:type="props.variant === 'server' ? 'warning' : 'critical'"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before deletion (${backupTip})` : 'Before deletion'"
:backup-name="props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion'"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -32,13 +32,17 @@
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled :color="variant === 'server' ? 'orange' : 'red'">
<button :disabled="buttonsDisabled" @click="confirm">
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<TrashIcon />
{{
formatMessage(messages.deleteButton, {
count,
itemType: formatContentTypeSentence(formatMessage, itemType, count),
count: props.count,
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
})
}}
</button>
@@ -82,16 +86,20 @@ const messages = defineMessages({
},
})
withDefaults(
const props = withDefaults(
defineProps<{
count: number
itemType: string
variant?: 'instance' | 'server'
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
variant: 'instance',
backupTip: undefined,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
@@ -108,6 +116,7 @@ function show() {
}
function confirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('delete')
}
@@ -35,7 +35,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="handleConfirm">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="handleConfirm"
>
<DownloadIcon />
{{
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
@@ -62,6 +66,8 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
const props = defineProps<{
downgrade?: boolean
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>()
const { formatMessage } = useVIntl()
@@ -106,6 +112,7 @@ function show() {
}
function handleConfirm() {
if (props.actionDisabled) return
modal.value?.hide()
emit('confirm')
}
@@ -43,12 +43,14 @@ import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ConfirmReinstallModal')
const messages = defineMessages({
header: {
@@ -84,12 +86,27 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
debug('show: called', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
})
modal.value?.show()
debug('show: returned from modal.show', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
})
}
function confirm() {
debug('confirm: called', {
hasModalRef: !!modal.value,
buttonsDisabled: buttonsDisabled.value,
})
modal.value?.hide()
emit('reinstall')
debug('confirm: emitted reinstall')
}
defineExpose({
@@ -37,6 +37,7 @@ import { ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -45,6 +46,7 @@ defineProps<{
}>()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ConfirmRepairModal')
const messages = defineMessages({
header: {
@@ -82,12 +84,16 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
debug('show: called', { hasModalRef: !!modal.value })
modal.value?.show()
debug('show: returned from modal.show', { hasModalRef: !!modal.value })
}
function confirm() {
debug('confirm: called', { hasModalRef: !!modal.value })
modal.value?.hide()
emit('repair')
debug('confirm: emitted repair')
}
defineExpose({
@@ -12,7 +12,7 @@
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before unlink (${backupTip})` : 'Before unlink'"
:backup-name="props.backupTip ? `Before unlink (${props.backupTip})` : 'Before unlink'"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -26,9 +26,13 @@
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="confirm">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="buttonsDisabled || props.actionDisabled"
@click="confirm"
>
<UnlinkIcon />
{{ formatMessage(messages.unlinkButton) }}
{{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
@@ -43,17 +47,21 @@ import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
defineProps<{
const props = defineProps<{
server?: boolean
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
}>()
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ConfirmUnlinkModal')
const messages = defineMessages({
header: {
@@ -84,12 +92,34 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
debug('show: called', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
actionDisabled: props.actionDisabled,
})
modal.value?.show()
debug('show: returned from modal.show', {
hasModalRef: !!modal.value,
hasBackupCreatorRef: !!backupCreator.value,
buttonsDisabled: buttonsDisabled.value,
actionDisabled: props.actionDisabled,
})
}
function confirm() {
debug('confirm: called', {
hasModalRef: !!modal.value,
buttonsDisabled: buttonsDisabled.value,
actionDisabled: props.actionDisabled,
})
if (props.actionDisabled) {
debug('confirm: ignored actionDisabled')
return
}
modal.value?.hide()
emit('unlink')
debug('confirm: emitted unlink')
}
defineExpose({
@@ -114,14 +114,14 @@
inst.name
}}</span>
</button>
<ButtonStyled v-if="inst.installed" :disabled="true">
<button>
<ButtonStyled v-if="inst.installed">
<button disabled>
<CheckIcon />
{{ formatMessage(messages.installedBadge) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="inst.compatible" :disabled="inst.installing">
<button @click="emit('install', inst)">
<ButtonStyled v-else-if="inst.compatible">
<button :disabled="inst.installing" @click="emit('install', inst)">
{{
inst.installing
? formatMessage(commonMessages.installingLabel)
@@ -216,7 +216,10 @@
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!selectedVersion || selectedVersion.id === currentVersionId"
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="
props.actionDisabled || !selectedVersion || selectedVersion.id === currentVersionId
"
@click="handleUpdate"
>
<DownloadIcon />
@@ -393,6 +396,8 @@ const props = withDefaults(
loading?: boolean
/** Whether changelog is being loaded for the selected version */
loadingChangelog?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string
}>(),
{
projectType: undefined,
@@ -401,6 +406,8 @@ const props = withDefaults(
header: undefined,
loading: false,
loadingChangelog: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
},
)
@@ -616,6 +623,7 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
}
function handleUpdate(event: MouseEvent) {
if (props.actionDisabled) return
if (selectedVersion.value) {
const changesGameVersion = versionChangesGameVersion(
selectedVersion.value,
@@ -13,13 +13,17 @@
<ButtonStyled v-if="!backup.backupComplete.value && !backup.backupFailed.value">
<button
v-tooltip="
backup.externalBackupInProgress.value
? formatMessage(messages.backupInProgress)
: undefined
!canManageBackups
? permissionDeniedMessage
: backup.externalBackupInProgress.value
? formatMessage(messages.backupInProgress)
: undefined
"
class="!shadow-none"
:disabled="backup.isBackingUp.value || backup.externalBackupInProgress.value"
@click="backup.startBackup()"
:disabled="
!canManageBackups || backup.isBackingUp.value || backup.externalBackupInProgress.value
"
@click="startBackup"
>
<SpinnerIcon v-if="backup.isBackingUp.value" class="size-5 animate-spin" />
<PlusIcon v-else class="size-5" />
@@ -55,10 +59,13 @@
<script setup lang="ts">
import { CheckCircleIcon, PlusIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
import { watch } from 'vue'
import { computed, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { hasServerPermission } from '#ui/composables/server-permissions'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { useInlineBackup } from '../../composables/use-inline-backup'
@@ -73,9 +80,25 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const serverCtx = injectModrinthServerContext(null)
const canManageBackups = computed(
() => !serverCtx || hasServerPermission(serverCtx.currentUserPermissions.value, 'BACKUPS'),
)
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
const backup = useInlineBackup(() => props.backupName)
function startBackup() {
if (
!canManageBackups.value ||
backup.externalBackupInProgress.value ||
backup.isBackingUp.value
) {
return
}
backup.startBackup()
}
watch(
() => backup.isBackingUp.value,
(backing) => {
@@ -36,7 +36,8 @@ interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
busy?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
}
@@ -45,7 +46,8 @@ const props = withDefaults(defineProps<Props>(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
busy: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
switchVersion: undefined,
})
@@ -54,6 +56,7 @@ const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
'bulk:enable': [items: ContentItem[]]
'bulk:disable': [items: ContentItem[]]
hide: []
}>()
const messages = defineMessages({
@@ -250,12 +253,16 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
installing: item.installing === true,
toggleDisabled: props.actionDisabled,
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
disabled: props.busy || disabledIds.value.has(item.file_name) || item.installing === true,
disabled:
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
overflowOptions: [
...(props.switchVersion
? [
@@ -286,20 +293,20 @@ function getTypeIcon(type: string) {
}
function handleEnabledChange(fileName: string, value: boolean) {
if (props.busy) return
if (props.actionDisabled) return
const item = items.value.find((i) => i.file_name === fileName)
if (!item) return
emit('update:enabled', item, value)
}
function bulkEnable() {
if (props.busy) return
if (props.actionDisabled) return
emit('bulk:enable', [...selectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
if (props.busy) return
if (props.actionDisabled) return
emit('bulk:disable', [...selectedItems.value])
selectedIds.value = []
}
@@ -326,6 +333,10 @@ function hide() {
modal.value?.hide()
}
function handleHide() {
emit('hide')
}
function getState(): ModpackContentModalState | null {
if (!items.value.length) return null
return {
@@ -383,6 +394,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
:on-hide="handleHide"
no-padding
>
<template #title>
@@ -558,7 +570,8 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<ContentSelectionBar
v-if="props.enableToggle"
:selected-items="selectedItems"
:is-bulk-operating="props.busy"
:is-busy="props.actionDisabled"
:busy-tooltip="props.actionDisabledTooltip"
style="--left-bar-width: 0px; --right-bar-width: 0px"
@clear="selectedIds = []"
@enable="bulkEnable"
@@ -272,6 +272,9 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
id,
disabled:
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
toggleDisabled: ctx.isBusy.value,
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
hasUpdate: item.has_update,
isClientOnly:
@@ -321,7 +324,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -331,7 +334,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
function showBulkDeleteModal(event?: MouseEvent) {
pendingDeletionItems.value = [...selectedItems.value]
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -339,6 +342,7 @@ function showBulkDeleteModal(event?: MouseEvent) {
}
async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
if (itemsToDelete.length === 0) return
@@ -383,6 +387,7 @@ async function confirmDelete() {
}
async function handleToggleEnabledById(id: string, _value: boolean) {
if (ctx.isBusy.value) return
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
markChanging(id)
@@ -394,6 +399,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
}
async function bulkEnable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => !item.enabled)
if (items.length === 0) return
if (ctx.bulkEnableItems) {
@@ -414,6 +420,7 @@ async function bulkEnable() {
}
async function bulkDisable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => item.enabled)
if (items.length === 0) return
if (ctx.bulkDisableItems) {
@@ -455,7 +462,7 @@ function promptUpdateAll(event?: MouseEvent) {
const items = ctx.items.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -467,7 +474,7 @@ function promptUpdateSelected(event?: MouseEvent) {
const items = selectedItems.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
if (event?.shiftKey) {
if (event?.shiftKey && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -475,6 +482,7 @@ function promptUpdateSelected(event?: MouseEvent) {
}
async function confirmBulkUpdate() {
if (ctx.isBusy.value) return
const items = pendingBulkUpdateItems.value
if (items.length === 0 || !hasBulkUpdateSupport.value) return
@@ -525,12 +533,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="
ctx.modpack.value.disabledText ??
ctx.busyMessage?.value ??
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
"
:disabled="ctx.modpack.value.disabled"
:disabled-text="ctx.modpack.value.disabledText"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
@@ -677,14 +681,18 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
color-fill="text"
hover-color-fill="background"
>
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
<button
v-tooltip="formatMessage(messages.updateAll)"
:disabled="isBulkOperating"
@click="promptUpdateAll"
>
<DownloadIcon />
{{ formatMessage(messages.updateAll) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
<button :disabled="refreshing" @click="handleRefresh">
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
@@ -768,6 +776,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:selected-items="selectedItems"
:content-type-label="ctx.contentTypeLabel.value"
:is-busy="ctx.isBusy.value"
:busy-tooltip="ctx.busyMessage?.value"
:is-bulk-operating="isBulkOperating"
:bulk-operation="bulkOperation"
:bulk-progress="bulkProgress"
@@ -789,7 +798,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
>
<button
v-tooltip="formatMessage(commonMessages.updateButton)"
:disabled="ctx.isBusy.value"
@click="promptUpdateSelected"
>
<DownloadIcon />
@@ -852,7 +860,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
>
<button
v-tooltip="formatMessage(commonMessages.deleteLabel)"
:disabled="ctx.isBusy.value"
@click="showBulkDeleteModal"
>
<TrashIcon />
@@ -868,6 +875,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ConfirmBulkUpdateModal
@@ -875,6 +884,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
ref="confirmBulkUpdateModal"
:count="pendingBulkUpdateItems.length"
:server="ctx.deletionContext === 'server'"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@update="confirmBulkUpdate"
/>
<ConfirmUnlinkModal
@@ -882,6 +893,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
ref="confirmUnlinkModal"
:server="ctx.deletionContext === 'server'"
:backup-tip="ctx.modpack.value?.project.title"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@unlink="ctx.unlinkModpack!()"
/>
@@ -32,6 +32,9 @@ export interface ContentCardTableItem {
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
disabledTooltip?: string | null
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
installing?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
@@ -10,6 +10,7 @@
<ButtonStyled type="transparent" circular>
<button
v-tooltip="formatMessage(messages.toggleReplace)"
:disabled="props.readonly"
:aria-label="formatMessage(messages.toggleReplace)"
@click="toggleReplace"
>
@@ -88,6 +89,7 @@
type="search"
size="small"
autocomplete="off"
:disabled="props.readonly"
:placeholder="formatMessage(messages.replaceInFile)"
wrapper-class="w-44"
/>
@@ -95,7 +97,7 @@
<ButtonStyled type="outlined">
<button
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
:disabled="findMatchCount === 0"
:disabled="props.readonly || findMatchCount === 0"
@click="emit('replace', replaceQuery)"
>
{{ formatMessage(messages.replace) }}
@@ -104,7 +106,7 @@
<ButtonStyled type="outlined">
<button
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
:disabled="findMatchCount === 0"
:disabled="props.readonly || findMatchCount === 0"
@click="emit('replaceAll', replaceQuery)"
>
{{ formatMessage(messages.replaceAll) }}
@@ -129,6 +131,7 @@ const props = defineProps<{
findMatchCount: number
currentFindMatch: number
isEditingImage: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
@@ -193,6 +196,7 @@ const findInputRef = ref<{ focus: () => void } | null>(null)
const replaceInputRef = ref<{ focus: () => void } | null>(null)
function toggleReplace() {
if (props.readonly) return
isReplaceOpen.value = !isReplaceOpen.value
if (isReplaceOpen.value) {
nextTick(() => replaceInputRef.value?.focus())
@@ -204,6 +208,7 @@ function focusFindInput() {
}
function openReplace() {
if (props.readonly) return
isReplaceOpen.value = true
nextTick(() => replaceInputRef.value?.focus())
}
@@ -8,6 +8,7 @@
v-model:is-find-open="isFindOpen"
v-model:find-query="inFileFindQuery"
:is-editing-image="isEditingImage"
:readonly="isEditorReadOnly"
:find-match-count="findMatchCount"
:current-find-match="currentFindMatch"
@find-next="findNext"
@@ -22,6 +23,7 @@
v-model:value="fileContent"
:lang="editorLanguage"
theme="modrinth"
:readonly="isEditorReadOnly"
:print-margin="false"
:style="{ height: editorHeight, fontSize: '0.875rem' }"
class="ace-modrinth rounded-[20px]"
@@ -144,6 +146,11 @@ const editorLanguage = computed(() => {
const ext = getFileExtension(props.file?.name ?? '')
return getEditorLanguage(ext)
})
const isEditorReadOnly = computed(() => ctx.isBusy?.value ?? false)
watch(isEditorReadOnly, (readOnly) => {
editorInstance.value?.setReadOnly(readOnly)
})
watch(
() => props.file,
@@ -206,6 +213,7 @@ function resetState() {
function onEditorInit(editor: Ace.Editor) {
editorInstance.value = editor
editor.setReadOnly(isEditorReadOnly.value)
editor.commands.addCommand({
name: 'save',
@@ -223,6 +231,7 @@ function onEditorInit(editor: Ace.Editor) {
name: 'replace',
bindKey: { win: 'Ctrl-H', mac: 'Command-Option-F' },
exec: () => {
if (isEditorReadOnly.value) return
isFindOpen.value = true
nextTick(() => findReplaceRef.value?.openReplace())
},
@@ -231,6 +240,7 @@ function onEditorInit(editor: Ace.Editor) {
async function saveFileContent(exit: boolean = false) {
if (!props.file) return
if (ctx.isBusy?.value) return
try {
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
@@ -312,7 +322,7 @@ function closeFind() {
function replaceOne(query: string) {
const editor = editorInstance.value
if (!editor || findMatchCount.value === 0) return
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
editor.replace(query)
nextTick(() => {
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
@@ -323,7 +333,7 @@ function replaceOne(query: string) {
function replaceAllOccurrences(query: string) {
const editor = editorInstance.value
if (!editor || findMatchCount.value === 0) return
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
editor.replaceAll(query)
nextTick(() => {
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
@@ -37,6 +37,7 @@
</div>
<StyledInput
v-model="url"
v-tooltip="props.disabled ? props.disabledTooltip : undefined"
:icon="LinkIcon"
type="url"
:placeholder="
@@ -44,7 +45,7 @@
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
:disabled="submitted"
:disabled="submitted || props.disabled"
:error="touched && !!error"
autocomplete="off"
@focus="touched = true"
@@ -74,8 +75,8 @@
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="error"
:disabled="submitted || !!error || backupInProgress"
v-tooltip="submitTooltip"
:disabled="submitDisabled"
type="submit"
@click="handleSubmit"
>
@@ -118,6 +119,17 @@ const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
disabled?: boolean
disabledTooltip?: string
}>(),
{
disabled: false,
disabledTooltip: undefined,
},
)
const messages = defineMessages({
cfHeader: {
id: 'files.zip-url-modal.cf-header',
@@ -239,9 +251,17 @@ const error = computed(() => {
return ''
})
const submitDisabled = computed(
() => submitted.value || props.disabled || !!error.value || backupInProgress.value,
)
const submitTooltip = computed(() => {
if (props.disabled) return props.disabledTooltip
return error.value || undefined
})
const handleSubmit = async () => {
touched.value = true
if (error.value) return
if (submitDisabled.value) return
submitted.value = true
try {
@@ -270,6 +290,8 @@ const handleSubmit = async () => {
}
const show = (isCf: boolean) => {
if (props.disabled) return
cf.value = isCf
url.value = ''
submitted.value = false
@@ -3,7 +3,12 @@
<FileUnsavedChangesModal ref="unsavedChangesModal" />
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
<FileUploadZipUrlModal v-if="ctx.showInstallFromUrl" ref="uploadZipUrlModal" />
<FileUploadZipUrlModal
v-if="ctx.showInstallFromUrl"
ref="uploadZipUrlModal"
:disabled="isBusy"
:disabled-tooltip="busyTooltip"
/>
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
<FileMoveItemModal
ref="moveItemModal"
@@ -156,7 +161,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="fileEditorRef?.saveFileContent(false)">
<button
v-tooltip="isBusy ? busyTooltip : undefined"
:disabled="isBusy"
@click="fileEditorRef?.saveFileContent(false)"
>
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
</button>
</ButtonStyled>
@@ -370,6 +379,7 @@ async function confirmDiscardChanges(): Promise<boolean> {
if (!hasUnsavedChanges.value) return true
const result = await unsavedChangesModal.value?.prompt()
if (result === 'save') {
if (isBusy.value) return false
await fileEditorRef.value?.saveFileContent(false)
return true
}
@@ -412,10 +422,12 @@ async function handleEditorClose() {
// CRUD handlers
async function handleCreateNewItem(name: string) {
if (isBusy.value) return
await ctx.createItem(name, newItemType.value)
}
async function handleRenameItem(newName: string) {
if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -432,6 +444,7 @@ async function handleRenameItem(newName: string) {
}
async function handleMoveItem(destination: string) {
if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -450,6 +463,7 @@ async function handleMoveItem(destination: string) {
}
function handleDeleteItem() {
if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -513,6 +527,7 @@ async function handleExtractItem(item: { name: string; type: string; path: strin
}
async function handleExtractConfirm(path: string) {
if (isBusy.value) return
if (!ctx.extractFile) return
try {
await ctx.extractFile(path, true, false)
@@ -2,6 +2,7 @@ import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { formatLoaderLabel } from '#ui/utils/loaders'
import type { ContentUpdaterModal } from '../../content-tab'
@@ -20,6 +21,7 @@ export function useInstallationForm(
InstanceType<typeof IncompatibleContentModal> | null | undefined
>,
) {
const debug = useDebugLogger('InstallationSettingsForm')
const isEditing = ref(false)
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
const selectedGameVersion = ctx.editingGameVersionRef ?? ref(ctx.currentGameVersion.value)
@@ -77,13 +79,50 @@ export function useInstallationForm(
})
watch(selectedPlatform, () => {
debug('selectedPlatform watch:', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
selectedLoaderVersion.value = 0
})
watch(selectedGameVersion, () => {
debug('selectedGameVersion watch:', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
selectedLoaderVersion.value = 0
})
watch(
[isEditing, isSaving, isVerifying, pendingPreview, incompatibleContentVariant],
(value, oldValue) => {
debug('state watch:', {
oldValue,
value,
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
isValid: isValid.value,
hasChanges: hasChanges.value,
})
},
)
async function save() {
debug('save: start', {
isBusy: ctx.isBusy.value,
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
isValid: isValid.value,
hasChanges: hasChanges.value,
})
if (ctx.isBusy.value) {
debug('save: ignored busy')
return
}
isSaving.value = true
try {
const platformChanged = selectedPlatform.value !== ctx.currentPlatform.value
@@ -91,22 +130,37 @@ export function useInstallationForm(
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
if (platformChanged && ctx.disableAllContent) {
debug('save: platform changed, showing incompatible modal', {
currentPlatform: ctx.currentPlatform.value,
selectedPlatform: selectedPlatform.value,
})
isSaving.value = false
incompatibleContentVariant.value = 'loader-change'
await nextTick()
debug('save: incompatible modal ref before show', {
hasRef: !!incompatibleContentModalRef?.value,
})
incompatibleContentModalRef?.value?.show()
return
}
if (isModded && gameVersionChanged && ctx.disableIncompatibleContent) {
debug('save: game version changed, showing incompatible modal', {
currentGameVersion: ctx.currentGameVersion.value,
selectedGameVersion: selectedGameVersion.value,
})
isSaving.value = false
incompatibleContentVariant.value = 'game-version-change'
await nextTick()
debug('save: incompatible modal ref before show', {
hasRef: !!incompatibleContentModalRef?.value,
})
incompatibleContentModalRef?.value?.show()
return
}
if (ctx.previewSave && isModded && gameVersionChanged) {
debug('save: previewSave start')
isVerifying.value = true
abortController = new AbortController()
const loaderVersionId =
@@ -128,8 +182,15 @@ export function useInstallationForm(
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
debug('save: preview returned diffs, showing content diff modal', {
diffs: preview.diffs.length,
hasUnknownContent: preview.hasUnknownContent,
})
pendingPreview.value = preview
await nextTick()
debug('save: content diff modal ref before show', {
hasRef: !!contentDiffModalRef?.value,
})
contentDiffModalRef?.value?.show()
return
}
@@ -137,11 +198,17 @@ export function useInstallationForm(
await performSave()
} catch {
debug('save: caught error, resetting isSaving')
isSaving.value = false
}
}
async function performSave() {
debug('performSave: start', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
})
try {
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
@@ -150,12 +217,19 @@ export function useInstallationForm(
await ctx.save(selectedPlatform.value, selectedGameVersion.value, loaderVersionId)
if (ctx.afterSave) await ctx.afterSave()
isEditing.value = false
debug('performSave: success')
} finally {
isSaving.value = false
debug('performSave: finally', { isSaving: isSaving.value, isEditing: isEditing.value })
}
}
async function confirmLoaderChange() {
debug('confirmLoaderChange: start', { isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('confirmLoaderChange: ignored busy')
return
}
try {
if (ctx.disableAllContent) {
await ctx.disableAllContent()
@@ -169,6 +243,11 @@ export function useInstallationForm(
}
async function confirmAutoFix() {
debug('confirmAutoFix: start', { isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('confirmAutoFix: ignored busy')
return
}
try {
if (ctx.previewSave) {
isVerifying.value = true
@@ -192,10 +271,17 @@ export function useInstallationForm(
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
debug('confirmAutoFix: preview returned diffs', {
diffs: preview.diffs.length,
hasUnknownContent: preview.hasUnknownContent,
})
pendingPreview.value = preview
incompatibleContentVariant.value = null
await nextTick()
await nextTick()
debug('confirmAutoFix: content diff modal ref before show', {
hasRef: !!contentDiffModalRef?.value,
})
contentDiffModalRef?.value?.show()
return
}
@@ -210,6 +296,11 @@ export function useInstallationForm(
}
async function confirmDisableConflicts() {
debug('confirmDisableConflicts: start', { isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('confirmDisableConflicts: ignored busy')
return
}
try {
if (ctx.disableIncompatibleContent) {
await ctx.disableIncompatibleContent(selectedGameVersion.value)
@@ -239,6 +330,14 @@ export function useInstallationForm(
}
async function confirmSave() {
debug('confirmSave: start', {
isBusy: ctx.isBusy.value,
hasPendingPreview: !!pendingPreview.value,
})
if (ctx.isBusy.value) {
debug('confirmSave: ignored busy')
return
}
pendingPreview.value = null
try {
await performSave()
@@ -248,12 +347,28 @@ export function useInstallationForm(
}
function cancelPreview() {
debug('cancelPreview: start', {
hasPendingPreview: !!pendingPreview.value,
incompatibleContentVariant: incompatibleContentVariant.value,
isSaving: isSaving.value,
})
pendingPreview.value = null
incompatibleContentVariant.value = null
isSaving.value = false
debug('cancelPreview: done')
}
function cancelEditing() {
debug('cancelEditing: start', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
currentPlatform: ctx.currentPlatform.value,
currentGameVersion: ctx.currentGameVersion.value,
currentLoaderVersion: ctx.currentLoaderVersion.value,
isSaving: isSaving.value,
isVerifying: isVerifying.value,
})
abortController?.abort()
abortController = null
isVerifying.value = false
@@ -271,6 +386,13 @@ export function useInstallationForm(
0,
)
isEditing.value = false
debug('cancelEditing: done', {
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
entries: entries.length,
isEditing: isEditing.value,
})
}
// Modpack updater state
@@ -279,35 +401,66 @@ export function useInstallationForm(
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
watch([updatingModpack, loadingVersions, loadingChangelog], (value, oldValue) => {
debug('updater state watch:', {
oldValue,
value,
versions: updatingProjectVersions.value.length,
selectedPlatform: selectedPlatform.value,
selectedGameVersion: selectedGameVersion.value,
})
})
async function handleChangeModpackVersion() {
debug('handleChangeModpackVersion: start', {
isBusy: ctx.isBusy.value,
currentVersionId: ctx.updaterModalProps.value.currentVersionId,
hasUpdaterRef: !!updaterModalRef.value,
})
if (ctx.isBusy.value) {
debug('handleChangeModpackVersion: ignored busy')
return
}
updatingModpack.value = true
loadingChangelog.value = false
const cached = ctx.getCachedModpackVersions()
if (cached) {
debug('handleChangeModpackVersion: using cached versions', { count: cached.length })
updatingProjectVersions.value = [...cached].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
loadingVersions.value = false
} else {
debug('handleChangeModpackVersion: no cached versions')
updatingProjectVersions.value = []
loadingVersions.value = true
}
await nextTick()
debug('handleChangeModpackVersion: showing updater modal', {
hasUpdaterRef: !!updaterModalRef.value,
versions: updatingProjectVersions.value.length,
})
updaterModalRef.value?.show(ctx.updaterModalProps.value.currentVersionId || undefined)
if (!cached) {
try {
debug('handleChangeModpackVersion: fetching versions')
const versions = await ctx.fetchModpackVersions()
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
debug('handleChangeModpackVersion: fetched versions', { count: versions.length })
} catch {
// Error handled by context
} finally {
loadingVersions.value = false
debug('handleChangeModpackVersion: fetch done', {
loadingVersions: loadingVersions.value,
versions: updatingProjectVersions.value.length,
})
}
}
}
@@ -322,6 +475,10 @@ export function useInstallationForm(
}
async function handleUpdaterVersionSelect(version: Labrinth.Versions.v2.Version) {
debug('handleUpdaterVersionSelect:', {
versionId: version.id,
hasChangelog: !!version.changelog,
})
if (version.changelog) return
loadingChangelog.value = true
try {
@@ -333,6 +490,10 @@ export function useInstallationForm(
}
async function handleUpdaterVersionHover(version: Labrinth.Versions.v2.Version) {
debug('handleUpdaterVersionHover:', {
versionId: version.id,
hasChangelog: !!version.changelog,
})
if (version.changelog) return
try {
const full = await ctx.getVersionChangelog(version.id)
@@ -343,17 +504,30 @@ export function useInstallationForm(
}
function resetUpdateState() {
debug('resetUpdateState: start', {
updatingModpack: updatingModpack.value,
versions: updatingProjectVersions.value.length,
loadingVersions: loadingVersions.value,
loadingChangelog: loadingChangelog.value,
})
updatingModpack.value = false
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
debug('resetUpdateState: done')
}
async function handleUpdaterConfirm(version: Labrinth.Versions.v2.Version) {
debug('handleUpdaterConfirm: start', { versionId: version.id, isBusy: ctx.isBusy.value })
if (ctx.isBusy.value) {
debug('handleUpdaterConfirm: ignored busy')
return
}
try {
await ctx.onModpackVersionConfirm(version)
} finally {
resetUpdateState()
debug('handleUpdaterConfirm: done')
}
}
@@ -13,7 +13,7 @@ import {
UnlinkIcon,
XIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
@@ -23,6 +23,7 @@ import Chips from '#ui/components/base/Chips.vue'
import Combobox from '#ui/components/base/Combobox.vue'
import PaperChannelBadge from '#ui/components/base/PaperChannelBadge.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
@@ -41,6 +42,7 @@ import type { LoaderVersionEntry } from './types'
const { formatMessage } = useVIntl()
const ctx = injectInstallationSettings()
const debug = useDebugLogger('InstallationSettingsLayout')
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const repairModal = ref<InstanceType<typeof ConfirmRepairModal>>()
@@ -61,6 +63,67 @@ const form = useInstallationForm(
incompatibleContentModal,
)
function stateSnapshot() {
return {
loading: ctx.loading.value,
isLinked: ctx.isLinked.value,
isBusy: ctx.isBusy.value,
busyMessage: ctx.busyMessage?.value,
isEditing: form.isEditing.value,
isSaving: form.isSaving.value,
isVerifying: form.isVerifying.value,
selectedPlatform: form.selectedPlatform.value,
selectedGameVersion: form.selectedGameVersion.value,
selectedLoaderVersion: form.selectedLoaderVersion.value,
hasChanges: form.hasChanges.value,
isValid: form.isValid.value,
updatingModpack: form.updatingModpack.value,
loadingVersions: form.loadingVersions.value,
pendingPreview: !!form.pendingPreview.value,
incompatibleContentVariant: form.incompatibleContentVariant.value,
repairing: ctx.repairing?.value,
reinstalling: ctx.reinstalling?.value,
}
}
function modalRefsSnapshot() {
return {
confirmLeaveModal: !!confirmLeaveModal.value,
repairModal: !!repairModal.value,
reinstallModal: !!reinstallModal.value,
unlinkModal: !!unlinkModal.value,
contentUpdaterModal: !!contentUpdaterModal.value,
contentDiffModal: !!contentDiffModal.value,
incompatibleContentModal: !!incompatibleContentModal.value,
modpackUpdateModal: !!modpackUpdateModal.value,
}
}
onMounted(() => {
debug('mounted', stateSnapshot(), modalRefsSnapshot())
})
onUpdated(() => {
debug('updated', stateSnapshot(), modalRefsSnapshot())
})
watch(
[
() => ctx.loading.value,
() => ctx.isLinked.value,
() => ctx.isBusy.value,
() => form.isEditing.value,
() => form.isSaving.value,
() => form.isVerifying.value,
() => form.updatingModpack.value,
() => form.pendingPreview.value,
() => form.incompatibleContentVariant.value,
],
(value, oldValue) => {
debug('state watch:', { oldValue, value, snapshot: stateSnapshot() })
},
)
function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag'] | null {
if (form.selectedPlatform.value !== 'paper') return null
const entries = ctx.resolveLoaderVersions(
@@ -68,6 +131,13 @@ function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag']
form.selectedGameVersion.value,
)
const tag = entries[index]?.channelTag
debug('paperLoaderChannelTag:', {
index,
selectedPlatform: form.selectedPlatform.value,
selectedGameVersion: form.selectedGameVersion.value,
entries: entries.length,
tag,
})
return tag === 'ALPHA' || tag === 'BETA' ? tag : null
}
@@ -82,6 +152,7 @@ if (typeof window !== 'undefined') {
watch(
() => form.isSaving.value,
(saving) => {
debug('isSaving watch:', { saving })
if (saving) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
@@ -91,10 +162,12 @@ if (typeof window !== 'undefined') {
)
onBeforeUnmount(() => {
debug('beforeUnmount', stateSnapshot(), modalRefsSnapshot())
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
debug('beforeRouteLeave:', stateSnapshot())
if (form.isSaving.value) {
return (await confirmLeaveModal.value?.prompt()) ?? false
}
@@ -106,6 +179,14 @@ const disabledPlatforms = computed(() => {
if (!ctx.lockPlatform || ctx.currentPlatform.value === 'vanilla') return []
return ctx.availablePlatforms.filter((p) => p !== ctx.currentPlatform.value)
})
const platformDisabledItems = computed(() =>
ctx.isBusy.value ? ctx.availablePlatforms : disabledPlatforms.value,
)
const platformDisabledTooltip = computed(() =>
ctx.isBusy.value
? (ctx.busyMessage?.value ?? undefined)
: formatMessage(messages.platformLockTooltip),
)
const showModpackVersionActions = computed(() => {
const val = ctx.showModpackVersionActions
@@ -120,6 +201,17 @@ const isLocalFile = computed(() => {
})
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
debug('handleModpackUpdateRequest: start', {
versionId: version.id,
versionNumber: version.version_number,
shiftKey: event?.shiftKey,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (ctx.isBusy.value) {
debug('handleModpackUpdateRequest: ignored busy')
return
}
pendingUpdateVersion.value = version
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
@@ -132,41 +224,83 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
if (event?.shiftKey || !shouldShowWarning) {
debug('handleModpackUpdateRequest: confirming without warning', {
isUpdateDowngrade: isUpdateDowngrade.value,
shouldShowWarning,
})
handleModpackUpdateConfirm()
return
}
debug('handleModpackUpdateRequest: showing confirm modal', {
isUpdateDowngrade: isUpdateDowngrade.value,
shouldShowWarning,
refs: modalRefsSnapshot(),
})
modpackUpdateModal.value?.show()
}
function handleModpackUpdateConfirm() {
debug('handleModpackUpdateConfirm: start', {
pendingVersionId: pendingUpdateVersion.value?.id,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (ctx.isBusy.value) {
debug('handleModpackUpdateConfirm: ignored busy')
return
}
const version = pendingUpdateVersion.value
if (version) {
debug('handleModpackUpdateConfirm: hiding updater and closing settings')
contentUpdaterModal.value?.hide()
form.cancelEditing()
ctx.closeSettings?.()
form.handleUpdaterConfirm(version)
pendingUpdateVersion.value = null
debug('handleModpackUpdateConfirm: done')
}
}
function handleModpackUpdateCancel() {
debug('handleModpackUpdateCancel', {
pendingVersionId: pendingUpdateVersion.value?.id,
snapshot: stateSnapshot(),
})
pendingUpdateVersion.value = null
}
function handleRepair() {
debug('handleRepair: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
if (ctx.isBusy.value) {
debug('handleRepair: ignored busy')
return
}
form.cancelEditing()
ctx.repair()
debug('handleRepair: invoked ctx.repair')
}
function handleReinstall() {
debug('handleReinstall: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
if (ctx.isBusy.value) {
debug('handleReinstall: ignored busy')
return
}
form.cancelEditing()
ctx.reinstallModpack()
debug('handleReinstall: invoked ctx.reinstallModpack')
}
function handleUnlink() {
debug('handleUnlink: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
if (ctx.isBusy.value) {
debug('handleUnlink: ignored busy')
return
}
form.cancelEditing()
ctx.unlinkModpack()
debug('handleUnlink: invoked ctx.unlinkModpack')
}
const emit = defineEmits<{
@@ -174,9 +308,90 @@ const emit = defineEmits<{
}>()
function handleIncompatibleResetServer() {
debug('handleIncompatibleResetServer: start', { snapshot: stateSnapshot() })
if (ctx.isBusy.value) {
debug('handleIncompatibleResetServer: ignored busy')
return
}
form.cancelPreview()
form.cancelEditing()
emit('reset-server')
debug('handleIncompatibleResetServer: emitted reset-server')
}
function handleStartEditing() {
debug('handleStartEditing: before', stateSnapshot())
form.isEditing.value = true
nextTick(() => {
debug('handleStartEditing: after nextTick', stateSnapshot())
})
}
function handleCancelEditing() {
debug('handleCancelEditing: before', stateSnapshot())
form.cancelEditing()
nextTick(() => {
debug('handleCancelEditing: after nextTick', stateSnapshot())
})
}
function handleSave() {
debug('handleSave: before', stateSnapshot())
void form.save().finally(() => {
debug('handleSave: after promise', stateSnapshot())
})
}
function handleShowRepairModal() {
debug('handleShowRepairModal: before show', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
repairModal.value?.show()
nextTick(() => {
debug('handleShowRepairModal: after nextTick', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
})
}
function handleShowUnlinkModal(event: MouseEvent) {
debug('handleShowUnlinkModal: before', {
shiftKey: event.shiftKey,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (event.shiftKey) {
handleUnlink()
return
}
unlinkModal.value?.show()
nextTick(() => {
debug('handleShowUnlinkModal: after nextTick', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
})
}
function handleShowReinstallModal(event: MouseEvent) {
debug('handleShowReinstallModal: before', {
shiftKey: event.shiftKey,
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (event.shiftKey) {
handleReinstall()
return
}
reinstallModal.value?.show()
nextTick(() => {
debug('handleShowReinstallModal: after nextTick', {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
})
}
defineExpose({
@@ -413,6 +628,7 @@ const messages = defineMessages({
<div class="flex flex-wrap gap-2">
<ButtonStyled v-if="showModpackVersionActions">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.handleChangeModpackVersion()"
@@ -438,9 +654,10 @@ const messages = defineMessages({
<div>
<ButtonStyled color="orange">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="(e: MouseEvent) => (e.shiftKey ? handleUnlink() : unlinkModal?.show())"
@click="handleShowUnlinkModal"
>
<UnlinkIcon class="size-5" />
{{
@@ -473,11 +690,10 @@ const messages = defineMessages({
<div>
<ButtonStyled color="red">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="
(e: MouseEvent) => (e.shiftKey ? handleReinstall() : reinstallModal?.show())
"
@click="handleShowReinstallModal"
>
<SpinnerIcon v-if="ctx.reinstalling?.value" class="animate-spin" />
<DownloadIcon v-else class="size-5" />
@@ -512,9 +728,10 @@ const messages = defineMessages({
<div>
<ButtonStyled>
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
@click="handleShowRepairModal"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
@@ -555,8 +772,8 @@ const messages = defineMessages({
:items="ctx.availablePlatforms"
:format-label="formatLoaderLabel"
:capitalize="false"
:disabled-items="disabledPlatforms"
:disabled-tooltip="formatMessage(messages.platformLockTooltip)"
:disabled-items="platformDisabledItems"
:disabled-tooltip="platformDisabledTooltip"
:aria-label="formatMessage(messages.selectPlatformAriaLabel)"
/>
</div>
@@ -567,6 +784,7 @@ const messages = defineMessages({
</span>
<Combobox
v-model="form.selectedGameVersion.value"
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
:options="form.gameVersionOptions.value"
searchable
sync-with-selection
@@ -577,11 +795,14 @@ const messages = defineMessages({
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
:disabled="ctx.isBusy.value"
@option-hover="ctx.onGameVersionHover?.($event)"
>
<template v-if="form.hasSnapshots.value" #dropdown-footer>
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
:disabled="ctx.isBusy.value"
@mousedown.prevent
@click="form.showSnapshots.value = !form.showSnapshots.value"
>
@@ -610,6 +831,7 @@ const messages = defineMessages({
</span>
<Combobox
v-model="form.selectedLoaderVersion.value"
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
searchable
sync-with-selection
:placeholder="
@@ -627,6 +849,7 @@ const messages = defineMessages({
loader: form.formattedLoaderName.value,
})
"
:disabled="ctx.isBusy.value"
>
<template
v-if="form.selectedPlatform.value === 'paper'"
@@ -659,9 +882,15 @@ const messages = defineMessages({
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="!form.isValid.value || !form.hasChanges.value || form.isSaving.value"
@click="form.save()"
:disabled="
!form.isValid.value ||
!form.hasChanges.value ||
form.isSaving.value ||
ctx.isBusy.value
"
@click="handleSave"
>
<SpinnerIcon v-if="form.isSaving.value" class="animate-spin" />
<SaveIcon v-else />
@@ -675,7 +904,7 @@ const messages = defineMessages({
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="form.cancelEditing()">
<button @click="handleCancelEditing">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
@@ -702,9 +931,10 @@ const messages = defineMessages({
<div class="flex flex-wrap gap-2">
<ButtonStyled color="orange">
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.isEditing.value = true"
@click="handleStartEditing"
>
<PencilIcon class="size-5" />
{{ formatMessage(commonMessages.editButton) }}
@@ -736,9 +966,10 @@ const messages = defineMessages({
<div>
<ButtonStyled>
<button
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
@click="handleShowRepairModal"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
@@ -16,6 +16,7 @@ export interface InstallationSettingsContext {
installationInfo: ComputedRef<InstallationInfoRow[]>
isLinked: ComputedRef<boolean>
isBusy: Ref<boolean> | ComputedRef<boolean>
busyMessage?: Ref<string | null> | ComputedRef<string | null>
modpack: Ref<InstallationModpackData | null> | ComputedRef<InstallationModpackData | null>
@@ -8,10 +8,13 @@
<span class="text-lg font-semibold text-contrast">SFTP</span>
<ButtonStyled>
<a
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
v-tooltip="sftpActionTooltip"
class="!w-full sm:!w-auto"
:href="sftpUrl"
:class="{ 'opacity-60': !canWriteFiles }"
:href="canWriteFiles ? sftpUrl : undefined"
:aria-disabled="!canWriteFiles"
target="_blank"
@click="handleSftpLaunchClick"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
@@ -22,8 +25,9 @@
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
<span class="text-lg font-semibold text-contrast">Server Address</span>
<div
v-tooltip="'Copy SFTP server address'"
v-tooltip="sftpCopyTooltip('Copy SFTP server address')"
class="copy-field hover:bg-button-bg-hover"
:class="{ 'opacity-60': !canWriteFiles }"
@click="copyToClipboard('Server address', server?.sftp_host)"
>
<span class="cursor-pointer font-semibold text-primary">
@@ -37,8 +41,9 @@
<div class="flex w-full flex-col justify-center gap-2">
<span class="text-lg font-semibold text-contrast">Username</span>
<div
v-tooltip="'Copy SFTP username'"
v-tooltip="sftpCopyTooltip('Copy SFTP username')"
class="copy-field hover:bg-button-bg-hover"
:class="{ 'opacity-60': !canWriteFiles }"
@click="copyToClipboard('Username', server?.sftp_username)"
>
<div class="truncate font-semibold">
@@ -53,11 +58,12 @@
<span class="text-lg font-semibold text-contrast">Password</span>
<div
class="copy-field-has-button [&:hover:not(:has(button:hover))]:bg-button-bg-hover"
:class="{ 'opacity-60': !canWriteFiles }"
@click="copyToClipboard('Password', server?.sftp_password)"
>
<div class="flex items-center gap-1.5 h-full w-full">
<div
v-tooltip="'Copy SFTP Password'"
v-tooltip="sftpCopyTooltip('Copy SFTP Password')"
class="h-full flex justify-between grow items-center"
>
<div class="truncate font-semibold">
@@ -72,9 +78,16 @@
<ButtonStyled type="transparent" circular>
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
v-tooltip="
canWriteFiles
? showPassword
? 'Hide password'
: 'Show password'
: permissionDeniedMessage
"
class="hover:bg-button-bg-hover grid h-10 w-10 place-content-center rounded-lg"
@click.stop="showPassword = !showPassword"
:disabled="!canWriteFiles"
@click.stop="togglePasswordVisibility"
>
<!-- look into doing stop propagation here -->
<EyeIcon v-if="showPassword" class="h-5 w-5" />
@@ -96,7 +109,12 @@
</label>
<ButtonStyled v-if="startupCommand !== defaultStartupCommand" type="transparent">
<button
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
v-tooltip="advancedActionTooltip"
:disabled="
isStartupLoading ||
startupCommand === defaultStartupCommand ||
!canUseAdvancedSettings
"
class="relative !w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -109,10 +127,11 @@
<StyledInput
id="startup-command-field"
v-model="startupCommand"
v-tooltip="advancedActionTooltip"
multiline
resize="vertical"
input-class="font-mono field-sizing-content"
:disabled="isStartupLoading"
:disabled="isStartupLoading || !canUseAdvancedSettings"
/>
<div
v-if="isStartupLoading"
@@ -133,10 +152,11 @@
<Combobox
:id="'java-version-field'"
v-model="javaVersion"
v-tooltip="advancedActionTooltip"
name="java-version"
:options="displayedJavaVersions"
:display-value="javaVersionLabel ?? 'Java Version'"
:disabled="isStartupLoading"
:disabled="isStartupLoading || !canUseAdvancedSettings"
>
<template #dropdown-footer>
<button
@@ -169,10 +189,11 @@
<Combobox
:id="'runtime-field'"
v-model="jreVendor"
v-tooltip="advancedActionTooltip"
name="runtime"
:options="JRE_VENDORS"
:display-value="jreVendorLabel ?? 'Runtime'"
:disabled="isStartupLoading"
:disabled="isStartupLoading || !canUseAdvancedSettings"
/>
<div
v-if="isStartupLoading"
@@ -189,7 +210,7 @@
:is-visible="!!hasUnsavedChanges || isPending"
:server-id="serverId"
:is-updating="isPending"
:save="() => saveStartup()"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
@@ -210,6 +231,7 @@ import { computed, ref, watch } from 'vue'
import { ButtonStyled, Combobox, StyledInput } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -220,12 +242,29 @@ const { addNotification } = injectNotificationManager()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { canUseAdvancedSettings, canWriteFiles, permissionDeniedMessage } = useServerPermissions()
// SFTP state
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
)
const sftpActionTooltip = computed(() =>
canWriteFiles.value
? 'This button only works with compatible SFTP clients (e.g. WinSCP)'
: permissionDeniedMessage.value,
)
const sftpCopyTooltip = (label: string) =>
canWriteFiles.value ? label : permissionDeniedMessage.value
function handleSftpLaunchClick(event: MouseEvent) {
if (canWriteFiles.value) return
event.preventDefault()
}
const copyToClipboard = (name: string, textToCopy?: string) => {
if (!canWriteFiles.value) return
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
@@ -242,6 +281,11 @@ const { data: startupData, isLoading: isStartupLoading } = useQuery({
enabled: computed(() => worldId.value !== null),
})
function togglePasswordVisibility() {
if (!canWriteFiles.value) return
showPassword.value = !showPassword.value
}
const JAVA_VERSIONS = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
@@ -343,7 +387,7 @@ const hasUnsavedChanges = computed(
jreVendor.value !== savedJreVendor.value,
)
const { mutate: saveStartup, isPending } = useMutation({
const { mutate: saveStartupMutation, isPending } = useMutation({
mutationFn: () =>
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
startup_command: startupCommand.value || null,
@@ -369,11 +413,17 @@ const { mutate: saveStartup, isPending } = useMutation({
},
})
function saveStartup() {
if (!canUseAdvancedSettings.value) return
saveStartupMutation()
}
function resetStartup() {
syncFormFromData()
}
function resetToDefault() {
if (!canUseAdvancedSettings.value) return
startupCommand.value = defaultStartupCommand.value
}
</script>

Some files were not shown because too many files have changed in this diff Show More