You've already forked AstralRinth
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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> {{ 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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
|
||||
+14
-5
@@ -8,11 +8,13 @@
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody, { count }) }}
|
||||
{{ formatMessage(messages.admonitionBody, { count: props.count }) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
|
||||
:backup-name="
|
||||
props.backupTip ? `Before bulk update (${props.backupTip})` : 'Before bulk update'
|
||||
"
|
||||
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
@@ -27,9 +29,13 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="buttonsDisabled || props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.updateButton, { count }) }}
|
||||
{{ formatMessage(messages.updateButton, { count: props.count }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -76,10 +82,12 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
count: number
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -95,6 +103,7 @@ function show() {
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('update')
|
||||
}
|
||||
|
||||
+18
-9
@@ -3,23 +3,23 @@
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
itemType: formatContentTypeSentence(formatMessage, itemType, count),
|
||||
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
|
||||
})
|
||||
"
|
||||
:fade="variant === 'server' ? 'warning' : 'danger'"
|
||||
:fade="props.variant === 'server' ? 'warning' : 'danger'"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition
|
||||
:type="variant === 'server' ? 'warning' : 'critical'"
|
||||
:type="props.variant === 'server' ? 'warning' : 'critical'"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="backupTip ? `Before deletion (${backupTip})` : 'Before deletion'"
|
||||
:backup-name="props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,13 +32,17 @@
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :color="variant === 'server' ? 'orange' : 'red'">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="buttonsDisabled || props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{
|
||||
formatMessage(messages.deleteButton, {
|
||||
count,
|
||||
itemType: formatContentTypeSentence(formatMessage, itemType, count),
|
||||
count: props.count,
|
||||
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
@@ -82,16 +86,20 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
count: number
|
||||
itemType: string
|
||||
variant?: 'instance' | 'server'
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'instance',
|
||||
backupTip: undefined,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -108,6 +116,7 @@ function show() {
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
+8
-1
@@ -35,7 +35,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="handleConfirm">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="buttonsDisabled || props.actionDisabled"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{
|
||||
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
|
||||
@@ -62,6 +66,8 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
const props = defineProps<{
|
||||
downgrade?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -106,6 +112,7 @@ function show() {
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
+17
@@ -43,12 +43,14 @@ import { ref } from 'vue'
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ConfirmReinstallModal')
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
@@ -84,12 +86,27 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
debug('show: called', {
|
||||
hasModalRef: !!modal.value,
|
||||
hasBackupCreatorRef: !!backupCreator.value,
|
||||
buttonsDisabled: buttonsDisabled.value,
|
||||
})
|
||||
modal.value?.show()
|
||||
debug('show: returned from modal.show', {
|
||||
hasModalRef: !!modal.value,
|
||||
hasBackupCreatorRef: !!backupCreator.value,
|
||||
buttonsDisabled: buttonsDisabled.value,
|
||||
})
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
debug('confirm: called', {
|
||||
hasModalRef: !!modal.value,
|
||||
buttonsDisabled: buttonsDisabled.value,
|
||||
})
|
||||
modal.value?.hide()
|
||||
emit('reinstall')
|
||||
debug('confirm: emitted reinstall')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -37,6 +37,7 @@ import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
@@ -45,6 +46,7 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ConfirmRepairModal')
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
@@ -82,12 +84,16 @@ const emit = defineEmits<{
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
debug('show: called', { hasModalRef: !!modal.value })
|
||||
modal.value?.show()
|
||||
debug('show: returned from modal.show', { hasModalRef: !!modal.value })
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
debug('confirm: called', { hasModalRef: !!modal.value })
|
||||
modal.value?.hide()
|
||||
emit('repair')
|
||||
debug('confirm: emitted repair')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
+34
-4
@@ -12,7 +12,7 @@
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="backupTip ? `Before unlink (${backupTip})` : 'Before unlink'"
|
||||
:backup-name="props.backupTip ? `Before unlink (${props.backupTip})` : 'Before unlink'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -26,9 +26,13 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="buttonsDisabled || props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<UnlinkIcon />
|
||||
{{ formatMessage(messages.unlinkButton) }}
|
||||
{{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -43,17 +47,21 @@ import { ref } from 'vue'
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ConfirmUnlinkModal')
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
@@ -84,12 +92,34 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
debug('show: called', {
|
||||
hasModalRef: !!modal.value,
|
||||
hasBackupCreatorRef: !!backupCreator.value,
|
||||
buttonsDisabled: buttonsDisabled.value,
|
||||
actionDisabled: props.actionDisabled,
|
||||
})
|
||||
modal.value?.show()
|
||||
debug('show: returned from modal.show', {
|
||||
hasModalRef: !!modal.value,
|
||||
hasBackupCreatorRef: !!backupCreator.value,
|
||||
buttonsDisabled: buttonsDisabled.value,
|
||||
actionDisabled: props.actionDisabled,
|
||||
})
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
debug('confirm: called', {
|
||||
hasModalRef: !!modal.value,
|
||||
buttonsDisabled: buttonsDisabled.value,
|
||||
actionDisabled: props.actionDisabled,
|
||||
})
|
||||
if (props.actionDisabled) {
|
||||
debug('confirm: ignored actionDisabled')
|
||||
return
|
||||
}
|
||||
modal.value?.hide()
|
||||
emit('unlink')
|
||||
debug('confirm: emitted unlink')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
+4
-4
@@ -114,14 +114,14 @@
|
||||
inst.name
|
||||
}}</span>
|
||||
</button>
|
||||
<ButtonStyled v-if="inst.installed" :disabled="true">
|
||||
<button>
|
||||
<ButtonStyled v-if="inst.installed">
|
||||
<button disabled>
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.installedBadge) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="inst.compatible" :disabled="inst.installing">
|
||||
<button @click="emit('install', inst)">
|
||||
<ButtonStyled v-else-if="inst.compatible">
|
||||
<button :disabled="inst.installing" @click="emit('install', inst)">
|
||||
{{
|
||||
inst.installing
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
|
||||
+9
-1
@@ -216,7 +216,10 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!selectedVersion || selectedVersion.id === currentVersionId"
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="
|
||||
props.actionDisabled || !selectedVersion || selectedVersion.id === currentVersionId
|
||||
"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
<DownloadIcon />
|
||||
@@ -393,6 +396,8 @@ const props = withDefaults(
|
||||
loading?: boolean
|
||||
/** Whether changelog is being loaded for the selected version */
|
||||
loadingChangelog?: boolean
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
projectType: undefined,
|
||||
@@ -401,6 +406,8 @@ const props = withDefaults(
|
||||
header: undefined,
|
||||
loading: false,
|
||||
loadingChangelog: false,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -616,6 +623,7 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
}
|
||||
|
||||
function handleUpdate(event: MouseEvent) {
|
||||
if (props.actionDisabled) return
|
||||
if (selectedVersion.value) {
|
||||
const changesGameVersion = versionChangesGameVersion(
|
||||
selectedVersion.value,
|
||||
|
||||
+29
-6
@@ -13,13 +13,17 @@
|
||||
<ButtonStyled v-if="!backup.backupComplete.value && !backup.backupFailed.value">
|
||||
<button
|
||||
v-tooltip="
|
||||
backup.externalBackupInProgress.value
|
||||
? formatMessage(messages.backupInProgress)
|
||||
: undefined
|
||||
!canManageBackups
|
||||
? permissionDeniedMessage
|
||||
: backup.externalBackupInProgress.value
|
||||
? formatMessage(messages.backupInProgress)
|
||||
: undefined
|
||||
"
|
||||
class="!shadow-none"
|
||||
:disabled="backup.isBackingUp.value || backup.externalBackupInProgress.value"
|
||||
@click="backup.startBackup()"
|
||||
:disabled="
|
||||
!canManageBackups || backup.isBackingUp.value || backup.externalBackupInProgress.value
|
||||
"
|
||||
@click="startBackup"
|
||||
>
|
||||
<SpinnerIcon v-if="backup.isBackingUp.value" class="size-5 animate-spin" />
|
||||
<PlusIcon v-else class="size-5" />
|
||||
@@ -55,10 +59,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, PlusIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { hasServerPermission } from '#ui/composables/server-permissions'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { useInlineBackup } from '../../composables/use-inline-backup'
|
||||
|
||||
@@ -73,9 +80,25 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const serverCtx = injectModrinthServerContext(null)
|
||||
const canManageBackups = computed(
|
||||
() => !serverCtx || hasServerPermission(serverCtx.currentUserPermissions.value, 'BACKUPS'),
|
||||
)
|
||||
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
|
||||
|
||||
const backup = useInlineBackup(() => props.backupName)
|
||||
|
||||
function startBackup() {
|
||||
if (
|
||||
!canManageBackups.value ||
|
||||
backup.externalBackupInProgress.value ||
|
||||
backup.isBackingUp.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
backup.startBackup()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => backup.isBackingUp.value,
|
||||
(backing) => {
|
||||
|
||||
+20
-7
@@ -36,7 +36,8 @@ interface Props {
|
||||
modpackName?: string
|
||||
modpackIconUrl?: string
|
||||
enableToggle?: boolean
|
||||
busy?: boolean
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string | null
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
switchVersion?: (item: ContentItem) => void
|
||||
}
|
||||
@@ -45,7 +46,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
modpackName: undefined,
|
||||
modpackIconUrl: undefined,
|
||||
enableToggle: false,
|
||||
busy: false,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
getOverflowOptions: undefined,
|
||||
switchVersion: undefined,
|
||||
})
|
||||
@@ -54,6 +56,7 @@ const emit = defineEmits<{
|
||||
'update:enabled': [item: ContentItem, value: boolean]
|
||||
'bulk:enable': [items: ContentItem[]]
|
||||
'bulk:disable': [items: ContentItem[]]
|
||||
hide: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -250,12 +253,16 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
: undefined,
|
||||
...(props.enableToggle ? { enabled: item.enabled } : {}),
|
||||
installing: item.installing === true,
|
||||
toggleDisabled: props.actionDisabled,
|
||||
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
|
||||
isClientOnly:
|
||||
isClientOnlyEnvironment(item.environment) ||
|
||||
!!item.pack_client_retained ||
|
||||
!!item.pack_client_depends,
|
||||
clientWarning: getClientWarningType(item),
|
||||
disabled: props.busy || disabledIds.value.has(item.file_name) || item.installing === true,
|
||||
disabled:
|
||||
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
|
||||
disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
|
||||
overflowOptions: [
|
||||
...(props.switchVersion
|
||||
? [
|
||||
@@ -286,20 +293,20 @@ function getTypeIcon(type: string) {
|
||||
}
|
||||
|
||||
function handleEnabledChange(fileName: string, value: boolean) {
|
||||
if (props.busy) return
|
||||
if (props.actionDisabled) return
|
||||
const item = items.value.find((i) => i.file_name === fileName)
|
||||
if (!item) return
|
||||
emit('update:enabled', item, value)
|
||||
}
|
||||
|
||||
function bulkEnable() {
|
||||
if (props.busy) return
|
||||
if (props.actionDisabled) return
|
||||
emit('bulk:enable', [...selectedItems.value])
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function bulkDisable() {
|
||||
if (props.busy) return
|
||||
if (props.actionDisabled) return
|
||||
emit('bulk:disable', [...selectedItems.value])
|
||||
selectedIds.value = []
|
||||
}
|
||||
@@ -326,6 +333,10 @@ function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
function getState(): ModpackContentModalState | null {
|
||||
if (!items.value.length) return null
|
||||
return {
|
||||
@@ -383,6 +394,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
ref="modal"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:on-hide="handleHide"
|
||||
no-padding
|
||||
>
|
||||
<template #title>
|
||||
@@ -558,7 +570,8 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
<ContentSelectionBar
|
||||
v-if="props.enableToggle"
|
||||
:selected-items="selectedItems"
|
||||
:is-bulk-operating="props.busy"
|
||||
:is-busy="props.actionDisabled"
|
||||
:busy-tooltip="props.actionDisabledTooltip"
|
||||
style="--left-bar-width: 0px; --right-bar-width: 0px"
|
||||
@clear="selectedIds = []"
|
||||
@enable="bulkEnable"
|
||||
|
||||
@@ -272,6 +272,9 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
id,
|
||||
disabled:
|
||||
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
|
||||
disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
|
||||
toggleDisabled: ctx.isBusy.value,
|
||||
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
|
||||
installing: item.installing === true,
|
||||
hasUpdate: item.has_update,
|
||||
isClientOnly:
|
||||
@@ -321,7 +324,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (item) {
|
||||
pendingDeletionItems.value = [item]
|
||||
if (event?.shiftKey) {
|
||||
if (event?.shiftKey && !ctx.isBusy.value) {
|
||||
confirmDelete()
|
||||
} else {
|
||||
confirmDeletionModal.value?.show()
|
||||
@@ -331,7 +334,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
|
||||
|
||||
function showBulkDeleteModal(event?: MouseEvent) {
|
||||
pendingDeletionItems.value = [...selectedItems.value]
|
||||
if (event?.shiftKey) {
|
||||
if (event?.shiftKey && !ctx.isBusy.value) {
|
||||
confirmDelete()
|
||||
} else {
|
||||
confirmDeletionModal.value?.show()
|
||||
@@ -339,6 +342,7 @@ function showBulkDeleteModal(event?: MouseEvent) {
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (ctx.isBusy.value) return
|
||||
const itemsToDelete = [...pendingDeletionItems.value]
|
||||
pendingDeletionItems.value = []
|
||||
if (itemsToDelete.length === 0) return
|
||||
@@ -383,6 +387,7 @@ async function confirmDelete() {
|
||||
}
|
||||
|
||||
async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
if (ctx.isBusy.value) return
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (!item) return
|
||||
markChanging(id)
|
||||
@@ -394,6 +399,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
}
|
||||
|
||||
async function bulkEnable() {
|
||||
if (ctx.isBusy.value) return
|
||||
const items = selectedItems.value.filter((item) => !item.enabled)
|
||||
if (items.length === 0) return
|
||||
if (ctx.bulkEnableItems) {
|
||||
@@ -414,6 +420,7 @@ async function bulkEnable() {
|
||||
}
|
||||
|
||||
async function bulkDisable() {
|
||||
if (ctx.isBusy.value) return
|
||||
const items = selectedItems.value.filter((item) => item.enabled)
|
||||
if (items.length === 0) return
|
||||
if (ctx.bulkDisableItems) {
|
||||
@@ -455,7 +462,7 @@ function promptUpdateAll(event?: MouseEvent) {
|
||||
const items = ctx.items.value.filter((item) => item.has_update)
|
||||
if (items.length === 0) return
|
||||
pendingBulkUpdateItems.value = items
|
||||
if (event?.shiftKey) {
|
||||
if (event?.shiftKey && !ctx.isBusy.value) {
|
||||
confirmBulkUpdate()
|
||||
} else {
|
||||
confirmBulkUpdateModal.value?.show()
|
||||
@@ -467,7 +474,7 @@ function promptUpdateSelected(event?: MouseEvent) {
|
||||
const items = selectedItems.value.filter((item) => item.has_update)
|
||||
if (items.length === 0) return
|
||||
pendingBulkUpdateItems.value = items
|
||||
if (event?.shiftKey) {
|
||||
if (event?.shiftKey && !ctx.isBusy.value) {
|
||||
confirmBulkUpdate()
|
||||
} else {
|
||||
confirmBulkUpdateModal.value?.show()
|
||||
@@ -475,6 +482,7 @@ function promptUpdateSelected(event?: MouseEvent) {
|
||||
}
|
||||
|
||||
async function confirmBulkUpdate() {
|
||||
if (ctx.isBusy.value) return
|
||||
const items = pendingBulkUpdateItems.value
|
||||
if (items.length === 0 || !hasBulkUpdateSupport.value) return
|
||||
|
||||
@@ -525,12 +533,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:owner="ctx.modpack.value.owner"
|
||||
:categories="ctx.modpack.value.categories"
|
||||
:has-update="ctx.modpack.value.hasUpdate"
|
||||
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
||||
:disabled-text="
|
||||
ctx.modpack.value.disabledText ??
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
||||
"
|
||||
:disabled="ctx.modpack.value.disabled"
|
||||
:disabled-text="ctx.modpack.value.disabledText"
|
||||
:show-content-hint="
|
||||
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
||||
"
|
||||
@@ -677,14 +681,18 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.updateAll)"
|
||||
:disabled="isBulkOperating"
|
||||
@click="promptUpdateAll"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.updateAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
||||
<button :disabled="refreshing" @click="handleRefresh">
|
||||
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
@@ -768,6 +776,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:selected-items="selectedItems"
|
||||
:content-type-label="ctx.contentTypeLabel.value"
|
||||
:is-busy="ctx.isBusy.value"
|
||||
:busy-tooltip="ctx.busyMessage?.value"
|
||||
:is-bulk-operating="isBulkOperating"
|
||||
:bulk-operation="bulkOperation"
|
||||
:bulk-progress="bulkProgress"
|
||||
@@ -789,7 +798,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.updateButton)"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="promptUpdateSelected"
|
||||
>
|
||||
<DownloadIcon />
|
||||
@@ -852,7 +860,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="showBulkDeleteModal"
|
||||
>
|
||||
<TrashIcon />
|
||||
@@ -868,6 +875,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:item-type="ctx.contentTypeLabel.value"
|
||||
:variant="ctx.deletionContext ?? 'instance'"
|
||||
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
|
||||
:action-disabled="ctx.isBusy.value"
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
<ConfirmBulkUpdateModal
|
||||
@@ -875,6 +884,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
ref="confirmBulkUpdateModal"
|
||||
:count="pendingBulkUpdateItems.length"
|
||||
:server="ctx.deletionContext === 'server'"
|
||||
:action-disabled="ctx.isBusy.value"
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@update="confirmBulkUpdate"
|
||||
/>
|
||||
<ConfirmUnlinkModal
|
||||
@@ -882,6 +893,8 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
ref="confirmUnlinkModal"
|
||||
:server="ctx.deletionContext === 'server'"
|
||||
:backup-tip="ctx.modpack.value?.project.title"
|
||||
:action-disabled="ctx.isBusy.value"
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@unlink="ctx.unlinkModpack!()"
|
||||
/>
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface ContentCardTableItem {
|
||||
owner?: ContentOwner
|
||||
enabled?: boolean
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string | null
|
||||
toggleDisabled?: boolean
|
||||
toggleDisabledTooltip?: string | null
|
||||
installing?: boolean
|
||||
hasUpdate?: boolean
|
||||
isClientOnly?: boolean
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.toggleReplace)"
|
||||
:disabled="props.readonly"
|
||||
:aria-label="formatMessage(messages.toggleReplace)"
|
||||
@click="toggleReplace"
|
||||
>
|
||||
@@ -88,6 +89,7 @@
|
||||
type="search"
|
||||
size="small"
|
||||
autocomplete="off"
|
||||
:disabled="props.readonly"
|
||||
:placeholder="formatMessage(messages.replaceInFile)"
|
||||
wrapper-class="w-44"
|
||||
/>
|
||||
@@ -95,7 +97,7 @@
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
|
||||
:disabled="findMatchCount === 0"
|
||||
:disabled="props.readonly || findMatchCount === 0"
|
||||
@click="emit('replace', replaceQuery)"
|
||||
>
|
||||
{{ formatMessage(messages.replace) }}
|
||||
@@ -104,7 +106,7 @@
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-8 whitespace-nowrap px-2 text-sm disabled:opacity-50"
|
||||
:disabled="findMatchCount === 0"
|
||||
:disabled="props.readonly || findMatchCount === 0"
|
||||
@click="emit('replaceAll', replaceQuery)"
|
||||
>
|
||||
{{ formatMessage(messages.replaceAll) }}
|
||||
@@ -129,6 +131,7 @@ const props = defineProps<{
|
||||
findMatchCount: number
|
||||
currentFindMatch: number
|
||||
isEditingImage: boolean
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -193,6 +196,7 @@ const findInputRef = ref<{ focus: () => void } | null>(null)
|
||||
const replaceInputRef = ref<{ focus: () => void } | null>(null)
|
||||
|
||||
function toggleReplace() {
|
||||
if (props.readonly) return
|
||||
isReplaceOpen.value = !isReplaceOpen.value
|
||||
if (isReplaceOpen.value) {
|
||||
nextTick(() => replaceInputRef.value?.focus())
|
||||
@@ -204,6 +208,7 @@ function focusFindInput() {
|
||||
}
|
||||
|
||||
function openReplace() {
|
||||
if (props.readonly) return
|
||||
isReplaceOpen.value = true
|
||||
nextTick(() => replaceInputRef.value?.focus())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
v-model:is-find-open="isFindOpen"
|
||||
v-model:find-query="inFileFindQuery"
|
||||
:is-editing-image="isEditingImage"
|
||||
:readonly="isEditorReadOnly"
|
||||
:find-match-count="findMatchCount"
|
||||
:current-find-match="currentFindMatch"
|
||||
@find-next="findNext"
|
||||
@@ -22,6 +23,7 @@
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:readonly="isEditorReadOnly"
|
||||
:print-margin="false"
|
||||
:style="{ height: editorHeight, fontSize: '0.875rem' }"
|
||||
class="ace-modrinth rounded-[20px]"
|
||||
@@ -144,6 +146,11 @@ const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
const isEditorReadOnly = computed(() => ctx.isBusy?.value ?? false)
|
||||
|
||||
watch(isEditorReadOnly, (readOnly) => {
|
||||
editorInstance.value?.setReadOnly(readOnly)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
@@ -206,6 +213,7 @@ function resetState() {
|
||||
|
||||
function onEditorInit(editor: Ace.Editor) {
|
||||
editorInstance.value = editor
|
||||
editor.setReadOnly(isEditorReadOnly.value)
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'save',
|
||||
@@ -223,6 +231,7 @@ function onEditorInit(editor: Ace.Editor) {
|
||||
name: 'replace',
|
||||
bindKey: { win: 'Ctrl-H', mac: 'Command-Option-F' },
|
||||
exec: () => {
|
||||
if (isEditorReadOnly.value) return
|
||||
isFindOpen.value = true
|
||||
nextTick(() => findReplaceRef.value?.openReplace())
|
||||
},
|
||||
@@ -231,6 +240,7 @@ function onEditorInit(editor: Ace.Editor) {
|
||||
|
||||
async function saveFileContent(exit: boolean = false) {
|
||||
if (!props.file) return
|
||||
if (ctx.isBusy?.value) return
|
||||
|
||||
try {
|
||||
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
|
||||
@@ -312,7 +322,7 @@ function closeFind() {
|
||||
|
||||
function replaceOne(query: string) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
|
||||
editor.replace(query)
|
||||
nextTick(() => {
|
||||
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
|
||||
@@ -323,7 +333,7 @@ function replaceOne(query: string) {
|
||||
|
||||
function replaceAllOccurrences(query: string) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
|
||||
editor.replaceAll(query)
|
||||
nextTick(() => {
|
||||
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
|
||||
|
||||
+26
-4
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
<StyledInput
|
||||
v-model="url"
|
||||
v-tooltip="props.disabled ? props.disabledTooltip : undefined"
|
||||
:icon="LinkIcon"
|
||||
type="url"
|
||||
:placeholder="
|
||||
@@ -44,7 +45,7 @@
|
||||
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||
"
|
||||
:disabled="submitted"
|
||||
:disabled="submitted || props.disabled"
|
||||
:error="touched && !!error"
|
||||
autocomplete="off"
|
||||
@focus="touched = true"
|
||||
@@ -74,8 +75,8 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="error"
|
||||
:disabled="submitted || !!error || backupInProgress"
|
||||
v-tooltip="submitTooltip"
|
||||
:disabled="submitDisabled"
|
||||
type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
@@ -118,6 +119,17 @@ const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
disabledTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
cfHeader: {
|
||||
id: 'files.zip-url-modal.cf-header',
|
||||
@@ -239,9 +251,17 @@ const error = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const submitDisabled = computed(
|
||||
() => submitted.value || props.disabled || !!error.value || backupInProgress.value,
|
||||
)
|
||||
const submitTooltip = computed(() => {
|
||||
if (props.disabled) return props.disabledTooltip
|
||||
return error.value || undefined
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
touched.value = true
|
||||
if (error.value) return
|
||||
if (submitDisabled.value) return
|
||||
|
||||
submitted.value = true
|
||||
try {
|
||||
@@ -270,6 +290,8 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
if (props.disabled) return
|
||||
|
||||
cf.value = isCf
|
||||
url.value = ''
|
||||
submitted.value = false
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<FileUnsavedChangesModal ref="unsavedChangesModal" />
|
||||
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
||||
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
|
||||
<FileUploadZipUrlModal v-if="ctx.showInstallFromUrl" ref="uploadZipUrlModal" />
|
||||
<FileUploadZipUrlModal
|
||||
v-if="ctx.showInstallFromUrl"
|
||||
ref="uploadZipUrlModal"
|
||||
:disabled="isBusy"
|
||||
:disabled-tooltip="busyTooltip"
|
||||
/>
|
||||
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
||||
<FileMoveItemModal
|
||||
ref="moveItemModal"
|
||||
@@ -156,7 +161,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="fileEditorRef?.saveFileContent(false)">
|
||||
<button
|
||||
v-tooltip="isBusy ? busyTooltip : undefined"
|
||||
:disabled="isBusy"
|
||||
@click="fileEditorRef?.saveFileContent(false)"
|
||||
>
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -370,6 +379,7 @@ async function confirmDiscardChanges(): Promise<boolean> {
|
||||
if (!hasUnsavedChanges.value) return true
|
||||
const result = await unsavedChangesModal.value?.prompt()
|
||||
if (result === 'save') {
|
||||
if (isBusy.value) return false
|
||||
await fileEditorRef.value?.saveFileContent(false)
|
||||
return true
|
||||
}
|
||||
@@ -412,10 +422,12 @@ async function handleEditorClose() {
|
||||
|
||||
// CRUD handlers
|
||||
async function handleCreateNewItem(name: string) {
|
||||
if (isBusy.value) return
|
||||
await ctx.createItem(name, newItemType.value)
|
||||
}
|
||||
|
||||
async function handleRenameItem(newName: string) {
|
||||
if (isBusy.value) return
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
@@ -432,6 +444,7 @@ async function handleRenameItem(newName: string) {
|
||||
}
|
||||
|
||||
async function handleMoveItem(destination: string) {
|
||||
if (isBusy.value) return
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
@@ -450,6 +463,7 @@ async function handleMoveItem(destination: string) {
|
||||
}
|
||||
|
||||
function handleDeleteItem() {
|
||||
if (isBusy.value) return
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
@@ -513,6 +527,7 @@ async function handleExtractItem(item: { name: string; type: string; path: strin
|
||||
}
|
||||
|
||||
async function handleExtractConfirm(path: string) {
|
||||
if (isBusy.value) return
|
||||
if (!ctx.extractFile) return
|
||||
try {
|
||||
await ctx.extractFile(path, true, false)
|
||||
|
||||
+174
@@ -2,6 +2,7 @@ import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import type { ContentUpdaterModal } from '../../content-tab'
|
||||
@@ -20,6 +21,7 @@ export function useInstallationForm(
|
||||
InstanceType<typeof IncompatibleContentModal> | null | undefined
|
||||
>,
|
||||
) {
|
||||
const debug = useDebugLogger('InstallationSettingsForm')
|
||||
const isEditing = ref(false)
|
||||
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
|
||||
const selectedGameVersion = ctx.editingGameVersionRef ?? ref(ctx.currentGameVersion.value)
|
||||
@@ -77,13 +79,50 @@ export function useInstallationForm(
|
||||
})
|
||||
|
||||
watch(selectedPlatform, () => {
|
||||
debug('selectedPlatform watch:', {
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
})
|
||||
selectedLoaderVersion.value = 0
|
||||
})
|
||||
watch(selectedGameVersion, () => {
|
||||
debug('selectedGameVersion watch:', {
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
})
|
||||
selectedLoaderVersion.value = 0
|
||||
})
|
||||
|
||||
watch(
|
||||
[isEditing, isSaving, isVerifying, pendingPreview, incompatibleContentVariant],
|
||||
(value, oldValue) => {
|
||||
debug('state watch:', {
|
||||
oldValue,
|
||||
value,
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
isValid: isValid.value,
|
||||
hasChanges: hasChanges.value,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
async function save() {
|
||||
debug('save: start', {
|
||||
isBusy: ctx.isBusy.value,
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
isValid: isValid.value,
|
||||
hasChanges: hasChanges.value,
|
||||
})
|
||||
if (ctx.isBusy.value) {
|
||||
debug('save: ignored busy')
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const platformChanged = selectedPlatform.value !== ctx.currentPlatform.value
|
||||
@@ -91,22 +130,37 @@ export function useInstallationForm(
|
||||
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
|
||||
|
||||
if (platformChanged && ctx.disableAllContent) {
|
||||
debug('save: platform changed, showing incompatible modal', {
|
||||
currentPlatform: ctx.currentPlatform.value,
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
})
|
||||
isSaving.value = false
|
||||
incompatibleContentVariant.value = 'loader-change'
|
||||
await nextTick()
|
||||
debug('save: incompatible modal ref before show', {
|
||||
hasRef: !!incompatibleContentModalRef?.value,
|
||||
})
|
||||
incompatibleContentModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (isModded && gameVersionChanged && ctx.disableIncompatibleContent) {
|
||||
debug('save: game version changed, showing incompatible modal', {
|
||||
currentGameVersion: ctx.currentGameVersion.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
})
|
||||
isSaving.value = false
|
||||
incompatibleContentVariant.value = 'game-version-change'
|
||||
await nextTick()
|
||||
debug('save: incompatible modal ref before show', {
|
||||
hasRef: !!incompatibleContentModalRef?.value,
|
||||
})
|
||||
incompatibleContentModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.previewSave && isModded && gameVersionChanged) {
|
||||
debug('save: previewSave start')
|
||||
isVerifying.value = true
|
||||
abortController = new AbortController()
|
||||
const loaderVersionId =
|
||||
@@ -128,8 +182,15 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
|
||||
debug('save: preview returned diffs, showing content diff modal', {
|
||||
diffs: preview.diffs.length,
|
||||
hasUnknownContent: preview.hasUnknownContent,
|
||||
})
|
||||
pendingPreview.value = preview
|
||||
await nextTick()
|
||||
debug('save: content diff modal ref before show', {
|
||||
hasRef: !!contentDiffModalRef?.value,
|
||||
})
|
||||
contentDiffModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
@@ -137,11 +198,17 @@ export function useInstallationForm(
|
||||
|
||||
await performSave()
|
||||
} catch {
|
||||
debug('save: caught error, resetting isSaving')
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function performSave() {
|
||||
debug('performSave: start', {
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
})
|
||||
try {
|
||||
const loaderVersionId =
|
||||
selectedPlatform.value !== 'vanilla'
|
||||
@@ -150,12 +217,19 @@ export function useInstallationForm(
|
||||
await ctx.save(selectedPlatform.value, selectedGameVersion.value, loaderVersionId)
|
||||
if (ctx.afterSave) await ctx.afterSave()
|
||||
isEditing.value = false
|
||||
debug('performSave: success')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
debug('performSave: finally', { isSaving: isSaving.value, isEditing: isEditing.value })
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLoaderChange() {
|
||||
debug('confirmLoaderChange: start', { isBusy: ctx.isBusy.value })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('confirmLoaderChange: ignored busy')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (ctx.disableAllContent) {
|
||||
await ctx.disableAllContent()
|
||||
@@ -169,6 +243,11 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
async function confirmAutoFix() {
|
||||
debug('confirmAutoFix: start', { isBusy: ctx.isBusy.value })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('confirmAutoFix: ignored busy')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (ctx.previewSave) {
|
||||
isVerifying.value = true
|
||||
@@ -192,10 +271,17 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
|
||||
debug('confirmAutoFix: preview returned diffs', {
|
||||
diffs: preview.diffs.length,
|
||||
hasUnknownContent: preview.hasUnknownContent,
|
||||
})
|
||||
pendingPreview.value = preview
|
||||
incompatibleContentVariant.value = null
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
debug('confirmAutoFix: content diff modal ref before show', {
|
||||
hasRef: !!contentDiffModalRef?.value,
|
||||
})
|
||||
contentDiffModalRef?.value?.show()
|
||||
return
|
||||
}
|
||||
@@ -210,6 +296,11 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
async function confirmDisableConflicts() {
|
||||
debug('confirmDisableConflicts: start', { isBusy: ctx.isBusy.value })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('confirmDisableConflicts: ignored busy')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (ctx.disableIncompatibleContent) {
|
||||
await ctx.disableIncompatibleContent(selectedGameVersion.value)
|
||||
@@ -239,6 +330,14 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
async function confirmSave() {
|
||||
debug('confirmSave: start', {
|
||||
isBusy: ctx.isBusy.value,
|
||||
hasPendingPreview: !!pendingPreview.value,
|
||||
})
|
||||
if (ctx.isBusy.value) {
|
||||
debug('confirmSave: ignored busy')
|
||||
return
|
||||
}
|
||||
pendingPreview.value = null
|
||||
try {
|
||||
await performSave()
|
||||
@@ -248,12 +347,28 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
function cancelPreview() {
|
||||
debug('cancelPreview: start', {
|
||||
hasPendingPreview: !!pendingPreview.value,
|
||||
incompatibleContentVariant: incompatibleContentVariant.value,
|
||||
isSaving: isSaving.value,
|
||||
})
|
||||
pendingPreview.value = null
|
||||
incompatibleContentVariant.value = null
|
||||
isSaving.value = false
|
||||
debug('cancelPreview: done')
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
debug('cancelEditing: start', {
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
currentPlatform: ctx.currentPlatform.value,
|
||||
currentGameVersion: ctx.currentGameVersion.value,
|
||||
currentLoaderVersion: ctx.currentLoaderVersion.value,
|
||||
isSaving: isSaving.value,
|
||||
isVerifying: isVerifying.value,
|
||||
})
|
||||
abortController?.abort()
|
||||
abortController = null
|
||||
isVerifying.value = false
|
||||
@@ -271,6 +386,13 @@ export function useInstallationForm(
|
||||
0,
|
||||
)
|
||||
isEditing.value = false
|
||||
debug('cancelEditing: done', {
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
selectedLoaderVersion: selectedLoaderVersion.value,
|
||||
entries: entries.length,
|
||||
isEditing: isEditing.value,
|
||||
})
|
||||
}
|
||||
|
||||
// Modpack updater state
|
||||
@@ -279,35 +401,66 @@ export function useInstallationForm(
|
||||
const loadingVersions = ref(false)
|
||||
const loadingChangelog = ref(false)
|
||||
|
||||
watch([updatingModpack, loadingVersions, loadingChangelog], (value, oldValue) => {
|
||||
debug('updater state watch:', {
|
||||
oldValue,
|
||||
value,
|
||||
versions: updatingProjectVersions.value.length,
|
||||
selectedPlatform: selectedPlatform.value,
|
||||
selectedGameVersion: selectedGameVersion.value,
|
||||
})
|
||||
})
|
||||
|
||||
async function handleChangeModpackVersion() {
|
||||
debug('handleChangeModpackVersion: start', {
|
||||
isBusy: ctx.isBusy.value,
|
||||
currentVersionId: ctx.updaterModalProps.value.currentVersionId,
|
||||
hasUpdaterRef: !!updaterModalRef.value,
|
||||
})
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleChangeModpackVersion: ignored busy')
|
||||
return
|
||||
}
|
||||
updatingModpack.value = true
|
||||
loadingChangelog.value = false
|
||||
|
||||
const cached = ctx.getCachedModpackVersions()
|
||||
if (cached) {
|
||||
debug('handleChangeModpackVersion: using cached versions', { count: cached.length })
|
||||
updatingProjectVersions.value = [...cached].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
loadingVersions.value = false
|
||||
} else {
|
||||
debug('handleChangeModpackVersion: no cached versions')
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
debug('handleChangeModpackVersion: showing updater modal', {
|
||||
hasUpdaterRef: !!updaterModalRef.value,
|
||||
versions: updatingProjectVersions.value.length,
|
||||
})
|
||||
updaterModalRef.value?.show(ctx.updaterModalProps.value.currentVersionId || undefined)
|
||||
|
||||
if (!cached) {
|
||||
try {
|
||||
debug('handleChangeModpackVersion: fetching versions')
|
||||
const versions = await ctx.fetchModpackVersions()
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
debug('handleChangeModpackVersion: fetched versions', { count: versions.length })
|
||||
} catch {
|
||||
// Error handled by context
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
debug('handleChangeModpackVersion: fetch done', {
|
||||
loadingVersions: loadingVersions.value,
|
||||
versions: updatingProjectVersions.value.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,6 +475,10 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
async function handleUpdaterVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
debug('handleUpdaterVersionSelect:', {
|
||||
versionId: version.id,
|
||||
hasChangelog: !!version.changelog,
|
||||
})
|
||||
if (version.changelog) return
|
||||
loadingChangelog.value = true
|
||||
try {
|
||||
@@ -333,6 +490,10 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
async function handleUpdaterVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
debug('handleUpdaterVersionHover:', {
|
||||
versionId: version.id,
|
||||
hasChangelog: !!version.changelog,
|
||||
})
|
||||
if (version.changelog) return
|
||||
try {
|
||||
const full = await ctx.getVersionChangelog(version.id)
|
||||
@@ -343,17 +504,30 @@ export function useInstallationForm(
|
||||
}
|
||||
|
||||
function resetUpdateState() {
|
||||
debug('resetUpdateState: start', {
|
||||
updatingModpack: updatingModpack.value,
|
||||
versions: updatingProjectVersions.value.length,
|
||||
loadingVersions: loadingVersions.value,
|
||||
loadingChangelog: loadingChangelog.value,
|
||||
})
|
||||
updatingModpack.value = false
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = false
|
||||
loadingChangelog.value = false
|
||||
debug('resetUpdateState: done')
|
||||
}
|
||||
|
||||
async function handleUpdaterConfirm(version: Labrinth.Versions.v2.Version) {
|
||||
debug('handleUpdaterConfirm: start', { versionId: version.id, isBusy: ctx.isBusy.value })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleUpdaterConfirm: ignored busy')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ctx.onModpackVersionConfirm(version)
|
||||
} finally {
|
||||
resetUpdateState()
|
||||
debug('handleUpdaterConfirm: done')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
UnlinkIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
@@ -23,6 +23,7 @@ import Chips from '#ui/components/base/Chips.vue'
|
||||
import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import PaperChannelBadge from '#ui/components/base/PaperChannelBadge.vue'
|
||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
@@ -41,6 +42,7 @@ import type { LoaderVersionEntry } from './types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectInstallationSettings()
|
||||
const debug = useDebugLogger('InstallationSettingsLayout')
|
||||
|
||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||
const repairModal = ref<InstanceType<typeof ConfirmRepairModal>>()
|
||||
@@ -61,6 +63,67 @@ const form = useInstallationForm(
|
||||
incompatibleContentModal,
|
||||
)
|
||||
|
||||
function stateSnapshot() {
|
||||
return {
|
||||
loading: ctx.loading.value,
|
||||
isLinked: ctx.isLinked.value,
|
||||
isBusy: ctx.isBusy.value,
|
||||
busyMessage: ctx.busyMessage?.value,
|
||||
isEditing: form.isEditing.value,
|
||||
isSaving: form.isSaving.value,
|
||||
isVerifying: form.isVerifying.value,
|
||||
selectedPlatform: form.selectedPlatform.value,
|
||||
selectedGameVersion: form.selectedGameVersion.value,
|
||||
selectedLoaderVersion: form.selectedLoaderVersion.value,
|
||||
hasChanges: form.hasChanges.value,
|
||||
isValid: form.isValid.value,
|
||||
updatingModpack: form.updatingModpack.value,
|
||||
loadingVersions: form.loadingVersions.value,
|
||||
pendingPreview: !!form.pendingPreview.value,
|
||||
incompatibleContentVariant: form.incompatibleContentVariant.value,
|
||||
repairing: ctx.repairing?.value,
|
||||
reinstalling: ctx.reinstalling?.value,
|
||||
}
|
||||
}
|
||||
|
||||
function modalRefsSnapshot() {
|
||||
return {
|
||||
confirmLeaveModal: !!confirmLeaveModal.value,
|
||||
repairModal: !!repairModal.value,
|
||||
reinstallModal: !!reinstallModal.value,
|
||||
unlinkModal: !!unlinkModal.value,
|
||||
contentUpdaterModal: !!contentUpdaterModal.value,
|
||||
contentDiffModal: !!contentDiffModal.value,
|
||||
incompatibleContentModal: !!incompatibleContentModal.value,
|
||||
modpackUpdateModal: !!modpackUpdateModal.value,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debug('mounted', stateSnapshot(), modalRefsSnapshot())
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
debug('updated', stateSnapshot(), modalRefsSnapshot())
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => ctx.loading.value,
|
||||
() => ctx.isLinked.value,
|
||||
() => ctx.isBusy.value,
|
||||
() => form.isEditing.value,
|
||||
() => form.isSaving.value,
|
||||
() => form.isVerifying.value,
|
||||
() => form.updatingModpack.value,
|
||||
() => form.pendingPreview.value,
|
||||
() => form.incompatibleContentVariant.value,
|
||||
],
|
||||
(value, oldValue) => {
|
||||
debug('state watch:', { oldValue, value, snapshot: stateSnapshot() })
|
||||
},
|
||||
)
|
||||
|
||||
function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag'] | null {
|
||||
if (form.selectedPlatform.value !== 'paper') return null
|
||||
const entries = ctx.resolveLoaderVersions(
|
||||
@@ -68,6 +131,13 @@ function paperLoaderChannelTag(index: number): LoaderVersionEntry['channelTag']
|
||||
form.selectedGameVersion.value,
|
||||
)
|
||||
const tag = entries[index]?.channelTag
|
||||
debug('paperLoaderChannelTag:', {
|
||||
index,
|
||||
selectedPlatform: form.selectedPlatform.value,
|
||||
selectedGameVersion: form.selectedGameVersion.value,
|
||||
entries: entries.length,
|
||||
tag,
|
||||
})
|
||||
return tag === 'ALPHA' || tag === 'BETA' ? tag : null
|
||||
}
|
||||
|
||||
@@ -82,6 +152,7 @@ if (typeof window !== 'undefined') {
|
||||
watch(
|
||||
() => form.isSaving.value,
|
||||
(saving) => {
|
||||
debug('isSaving watch:', { saving })
|
||||
if (saving) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
@@ -91,10 +162,12 @@ if (typeof window !== 'undefined') {
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debug('beforeUnmount', stateSnapshot(), modalRefsSnapshot())
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
debug('beforeRouteLeave:', stateSnapshot())
|
||||
if (form.isSaving.value) {
|
||||
return (await confirmLeaveModal.value?.prompt()) ?? false
|
||||
}
|
||||
@@ -106,6 +179,14 @@ const disabledPlatforms = computed(() => {
|
||||
if (!ctx.lockPlatform || ctx.currentPlatform.value === 'vanilla') return []
|
||||
return ctx.availablePlatforms.filter((p) => p !== ctx.currentPlatform.value)
|
||||
})
|
||||
const platformDisabledItems = computed(() =>
|
||||
ctx.isBusy.value ? ctx.availablePlatforms : disabledPlatforms.value,
|
||||
)
|
||||
const platformDisabledTooltip = computed(() =>
|
||||
ctx.isBusy.value
|
||||
? (ctx.busyMessage?.value ?? undefined)
|
||||
: formatMessage(messages.platformLockTooltip),
|
||||
)
|
||||
|
||||
const showModpackVersionActions = computed(() => {
|
||||
const val = ctx.showModpackVersionActions
|
||||
@@ -120,6 +201,17 @@ const isLocalFile = computed(() => {
|
||||
})
|
||||
|
||||
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
debug('handleModpackUpdateRequest: start', {
|
||||
versionId: version.id,
|
||||
versionNumber: version.version_number,
|
||||
shiftKey: event?.shiftKey,
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleModpackUpdateRequest: ignored busy')
|
||||
return
|
||||
}
|
||||
pendingUpdateVersion.value = version
|
||||
|
||||
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
|
||||
@@ -132,41 +224,83 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
|
||||
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
|
||||
|
||||
if (event?.shiftKey || !shouldShowWarning) {
|
||||
debug('handleModpackUpdateRequest: confirming without warning', {
|
||||
isUpdateDowngrade: isUpdateDowngrade.value,
|
||||
shouldShowWarning,
|
||||
})
|
||||
handleModpackUpdateConfirm()
|
||||
return
|
||||
}
|
||||
|
||||
debug('handleModpackUpdateRequest: showing confirm modal', {
|
||||
isUpdateDowngrade: isUpdateDowngrade.value,
|
||||
shouldShowWarning,
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
modpackUpdateModal.value?.show()
|
||||
}
|
||||
|
||||
function handleModpackUpdateConfirm() {
|
||||
debug('handleModpackUpdateConfirm: start', {
|
||||
pendingVersionId: pendingUpdateVersion.value?.id,
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleModpackUpdateConfirm: ignored busy')
|
||||
return
|
||||
}
|
||||
const version = pendingUpdateVersion.value
|
||||
if (version) {
|
||||
debug('handleModpackUpdateConfirm: hiding updater and closing settings')
|
||||
contentUpdaterModal.value?.hide()
|
||||
form.cancelEditing()
|
||||
ctx.closeSettings?.()
|
||||
form.handleUpdaterConfirm(version)
|
||||
pendingUpdateVersion.value = null
|
||||
debug('handleModpackUpdateConfirm: done')
|
||||
}
|
||||
}
|
||||
|
||||
function handleModpackUpdateCancel() {
|
||||
debug('handleModpackUpdateCancel', {
|
||||
pendingVersionId: pendingUpdateVersion.value?.id,
|
||||
snapshot: stateSnapshot(),
|
||||
})
|
||||
pendingUpdateVersion.value = null
|
||||
}
|
||||
|
||||
function handleRepair() {
|
||||
debug('handleRepair: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleRepair: ignored busy')
|
||||
return
|
||||
}
|
||||
form.cancelEditing()
|
||||
ctx.repair()
|
||||
debug('handleRepair: invoked ctx.repair')
|
||||
}
|
||||
|
||||
function handleReinstall() {
|
||||
debug('handleReinstall: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleReinstall: ignored busy')
|
||||
return
|
||||
}
|
||||
form.cancelEditing()
|
||||
ctx.reinstallModpack()
|
||||
debug('handleReinstall: invoked ctx.reinstallModpack')
|
||||
}
|
||||
|
||||
function handleUnlink() {
|
||||
debug('handleUnlink: start', { snapshot: stateSnapshot(), refs: modalRefsSnapshot() })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleUnlink: ignored busy')
|
||||
return
|
||||
}
|
||||
form.cancelEditing()
|
||||
ctx.unlinkModpack()
|
||||
debug('handleUnlink: invoked ctx.unlinkModpack')
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -174,9 +308,90 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
function handleIncompatibleResetServer() {
|
||||
debug('handleIncompatibleResetServer: start', { snapshot: stateSnapshot() })
|
||||
if (ctx.isBusy.value) {
|
||||
debug('handleIncompatibleResetServer: ignored busy')
|
||||
return
|
||||
}
|
||||
form.cancelPreview()
|
||||
form.cancelEditing()
|
||||
emit('reset-server')
|
||||
debug('handleIncompatibleResetServer: emitted reset-server')
|
||||
}
|
||||
|
||||
function handleStartEditing() {
|
||||
debug('handleStartEditing: before', stateSnapshot())
|
||||
form.isEditing.value = true
|
||||
nextTick(() => {
|
||||
debug('handleStartEditing: after nextTick', stateSnapshot())
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancelEditing() {
|
||||
debug('handleCancelEditing: before', stateSnapshot())
|
||||
form.cancelEditing()
|
||||
nextTick(() => {
|
||||
debug('handleCancelEditing: after nextTick', stateSnapshot())
|
||||
})
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
debug('handleSave: before', stateSnapshot())
|
||||
void form.save().finally(() => {
|
||||
debug('handleSave: after promise', stateSnapshot())
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowRepairModal() {
|
||||
debug('handleShowRepairModal: before show', {
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
repairModal.value?.show()
|
||||
nextTick(() => {
|
||||
debug('handleShowRepairModal: after nextTick', {
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowUnlinkModal(event: MouseEvent) {
|
||||
debug('handleShowUnlinkModal: before', {
|
||||
shiftKey: event.shiftKey,
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
if (event.shiftKey) {
|
||||
handleUnlink()
|
||||
return
|
||||
}
|
||||
unlinkModal.value?.show()
|
||||
nextTick(() => {
|
||||
debug('handleShowUnlinkModal: after nextTick', {
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowReinstallModal(event: MouseEvent) {
|
||||
debug('handleShowReinstallModal: before', {
|
||||
shiftKey: event.shiftKey,
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
if (event.shiftKey) {
|
||||
handleReinstall()
|
||||
return
|
||||
}
|
||||
reinstallModal.value?.show()
|
||||
nextTick(() => {
|
||||
debug('handleShowReinstallModal: after nextTick', {
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -413,6 +628,7 @@ const messages = defineMessages({
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled v-if="showModpackVersionActions">
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="form.handleChangeModpackVersion()"
|
||||
@@ -438,9 +654,10 @@ const messages = defineMessages({
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="(e: MouseEvent) => (e.shiftKey ? handleUnlink() : unlinkModal?.show())"
|
||||
@click="handleShowUnlinkModal"
|
||||
>
|
||||
<UnlinkIcon class="size-5" />
|
||||
{{
|
||||
@@ -473,11 +690,10 @@ const messages = defineMessages({
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="
|
||||
(e: MouseEvent) => (e.shiftKey ? handleReinstall() : reinstallModal?.show())
|
||||
"
|
||||
@click="handleShowReinstallModal"
|
||||
>
|
||||
<SpinnerIcon v-if="ctx.reinstalling?.value" class="animate-spin" />
|
||||
<DownloadIcon v-else class="size-5" />
|
||||
@@ -512,9 +728,10 @@ const messages = defineMessages({
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="repairModal?.show()"
|
||||
@click="handleShowRepairModal"
|
||||
>
|
||||
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
|
||||
<HammerIcon v-else class="size-5" />
|
||||
@@ -555,8 +772,8 @@ const messages = defineMessages({
|
||||
:items="ctx.availablePlatforms"
|
||||
:format-label="formatLoaderLabel"
|
||||
:capitalize="false"
|
||||
:disabled-items="disabledPlatforms"
|
||||
:disabled-tooltip="formatMessage(messages.platformLockTooltip)"
|
||||
:disabled-items="platformDisabledItems"
|
||||
:disabled-tooltip="platformDisabledTooltip"
|
||||
:aria-label="formatMessage(messages.selectPlatformAriaLabel)"
|
||||
/>
|
||||
</div>
|
||||
@@ -567,6 +784,7 @@ const messages = defineMessages({
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="form.selectedGameVersion.value"
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
:options="form.gameVersionOptions.value"
|
||||
searchable
|
||||
sync-with-selection
|
||||
@@ -577,11 +795,14 @@ const messages = defineMessages({
|
||||
formatMessage(commonMessages.selectVersionPlaceholder)
|
||||
"
|
||||
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@option-hover="ctx.onGameVersionHover?.($event)"
|
||||
>
|
||||
<template v-if="form.hasSnapshots.value" #dropdown-footer>
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@mousedown.prevent
|
||||
@click="form.showSnapshots.value = !form.showSnapshots.value"
|
||||
>
|
||||
@@ -610,6 +831,7 @@ const messages = defineMessages({
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="form.selectedLoaderVersion.value"
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
searchable
|
||||
sync-with-selection
|
||||
:placeholder="
|
||||
@@ -627,6 +849,7 @@ const messages = defineMessages({
|
||||
loader: form.formattedLoaderName.value,
|
||||
})
|
||||
"
|
||||
:disabled="ctx.isBusy.value"
|
||||
>
|
||||
<template
|
||||
v-if="form.selectedPlatform.value === 'paper'"
|
||||
@@ -659,9 +882,15 @@ const messages = defineMessages({
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="!form.isValid.value || !form.hasChanges.value || form.isSaving.value"
|
||||
@click="form.save()"
|
||||
:disabled="
|
||||
!form.isValid.value ||
|
||||
!form.hasChanges.value ||
|
||||
form.isSaving.value ||
|
||||
ctx.isBusy.value
|
||||
"
|
||||
@click="handleSave"
|
||||
>
|
||||
<SpinnerIcon v-if="form.isSaving.value" class="animate-spin" />
|
||||
<SaveIcon v-else />
|
||||
@@ -675,7 +904,7 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="form.cancelEditing()">
|
||||
<button @click="handleCancelEditing">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
@@ -702,9 +931,10 @@ const messages = defineMessages({
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="form.isEditing.value = true"
|
||||
@click="handleStartEditing"
|
||||
>
|
||||
<PencilIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.editButton) }}
|
||||
@@ -736,9 +966,10 @@ const messages = defineMessages({
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="repairModal?.show()"
|
||||
@click="handleShowRepairModal"
|
||||
>
|
||||
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
|
||||
<HammerIcon v-else class="size-5" />
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export interface InstallationSettingsContext {
|
||||
installationInfo: ComputedRef<InstallationInfoRow[]>
|
||||
isLinked: ComputedRef<boolean>
|
||||
isBusy: Ref<boolean> | ComputedRef<boolean>
|
||||
busyMessage?: Ref<string | null> | ComputedRef<string | null>
|
||||
|
||||
modpack: Ref<InstallationModpackData | null> | ComputedRef<InstallationModpackData | null>
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
<span class="text-lg font-semibold text-contrast">SFTP</span>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
|
||||
v-tooltip="sftpActionTooltip"
|
||||
class="!w-full sm:!w-auto"
|
||||
:href="sftpUrl"
|
||||
:class="{ 'opacity-60': !canWriteFiles }"
|
||||
:href="canWriteFiles ? sftpUrl : undefined"
|
||||
:aria-disabled="!canWriteFiles"
|
||||
target="_blank"
|
||||
@click="handleSftpLaunchClick"
|
||||
>
|
||||
<ExternalIcon class="h-5 w-5" />
|
||||
Launch SFTP
|
||||
@@ -22,8 +25,9 @@
|
||||
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
|
||||
<span class="text-lg font-semibold text-contrast">Server Address</span>
|
||||
<div
|
||||
v-tooltip="'Copy SFTP server address'"
|
||||
v-tooltip="sftpCopyTooltip('Copy SFTP server address')"
|
||||
class="copy-field hover:bg-button-bg-hover"
|
||||
:class="{ 'opacity-60': !canWriteFiles }"
|
||||
@click="copyToClipboard('Server address', server?.sftp_host)"
|
||||
>
|
||||
<span class="cursor-pointer font-semibold text-primary">
|
||||
@@ -37,8 +41,9 @@
|
||||
<div class="flex w-full flex-col justify-center gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Username</span>
|
||||
<div
|
||||
v-tooltip="'Copy SFTP username'"
|
||||
v-tooltip="sftpCopyTooltip('Copy SFTP username')"
|
||||
class="copy-field hover:bg-button-bg-hover"
|
||||
:class="{ 'opacity-60': !canWriteFiles }"
|
||||
@click="copyToClipboard('Username', server?.sftp_username)"
|
||||
>
|
||||
<div class="truncate font-semibold">
|
||||
@@ -53,11 +58,12 @@
|
||||
<span class="text-lg font-semibold text-contrast">Password</span>
|
||||
<div
|
||||
class="copy-field-has-button [&:hover:not(:has(button:hover))]:bg-button-bg-hover"
|
||||
:class="{ 'opacity-60': !canWriteFiles }"
|
||||
@click="copyToClipboard('Password', server?.sftp_password)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 h-full w-full">
|
||||
<div
|
||||
v-tooltip="'Copy SFTP Password'"
|
||||
v-tooltip="sftpCopyTooltip('Copy SFTP Password')"
|
||||
class="h-full flex justify-between grow items-center"
|
||||
>
|
||||
<div class="truncate font-semibold">
|
||||
@@ -72,9 +78,16 @@
|
||||
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
|
||||
v-tooltip="
|
||||
canWriteFiles
|
||||
? showPassword
|
||||
? 'Hide password'
|
||||
: 'Show password'
|
||||
: permissionDeniedMessage
|
||||
"
|
||||
class="hover:bg-button-bg-hover grid h-10 w-10 place-content-center rounded-lg"
|
||||
@click.stop="showPassword = !showPassword"
|
||||
:disabled="!canWriteFiles"
|
||||
@click.stop="togglePasswordVisibility"
|
||||
>
|
||||
<!-- look into doing stop propagation here -->
|
||||
<EyeIcon v-if="showPassword" class="h-5 w-5" />
|
||||
@@ -96,7 +109,12 @@
|
||||
</label>
|
||||
<ButtonStyled v-if="startupCommand !== defaultStartupCommand" type="transparent">
|
||||
<button
|
||||
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
|
||||
v-tooltip="advancedActionTooltip"
|
||||
:disabled="
|
||||
isStartupLoading ||
|
||||
startupCommand === defaultStartupCommand ||
|
||||
!canUseAdvancedSettings
|
||||
"
|
||||
class="relative !w-full sm:!w-auto"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
@@ -109,10 +127,11 @@
|
||||
<StyledInput
|
||||
id="startup-command-field"
|
||||
v-model="startupCommand"
|
||||
v-tooltip="advancedActionTooltip"
|
||||
multiline
|
||||
resize="vertical"
|
||||
input-class="font-mono field-sizing-content"
|
||||
:disabled="isStartupLoading"
|
||||
:disabled="isStartupLoading || !canUseAdvancedSettings"
|
||||
/>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
@@ -133,10 +152,11 @@
|
||||
<Combobox
|
||||
:id="'java-version-field'"
|
||||
v-model="javaVersion"
|
||||
v-tooltip="advancedActionTooltip"
|
||||
name="java-version"
|
||||
:options="displayedJavaVersions"
|
||||
:display-value="javaVersionLabel ?? 'Java Version'"
|
||||
:disabled="isStartupLoading"
|
||||
:disabled="isStartupLoading || !canUseAdvancedSettings"
|
||||
>
|
||||
<template #dropdown-footer>
|
||||
<button
|
||||
@@ -169,10 +189,11 @@
|
||||
<Combobox
|
||||
:id="'runtime-field'"
|
||||
v-model="jreVendor"
|
||||
v-tooltip="advancedActionTooltip"
|
||||
name="runtime"
|
||||
:options="JRE_VENDORS"
|
||||
:display-value="jreVendorLabel ?? 'Runtime'"
|
||||
:disabled="isStartupLoading"
|
||||
:disabled="isStartupLoading || !canUseAdvancedSettings"
|
||||
/>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
@@ -189,7 +210,7 @@
|
||||
:is-visible="!!hasUnsavedChanges || isPending"
|
||||
:server-id="serverId"
|
||||
:is-updating="isPending"
|
||||
:save="() => saveStartup()"
|
||||
:save="saveStartup"
|
||||
:reset="resetStartup"
|
||||
/>
|
||||
</div>
|
||||
@@ -210,6 +231,7 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { ButtonStyled, Combobox, StyledInput } from '#ui/components'
|
||||
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
@@ -220,12 +242,29 @@ const { addNotification } = injectNotificationManager()
|
||||
const { server, serverId, worldId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { canUseAdvancedSettings, canWriteFiles, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
// SFTP state
|
||||
const showPassword = ref(false)
|
||||
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
|
||||
const advancedActionTooltip = computed(() =>
|
||||
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
|
||||
)
|
||||
const sftpActionTooltip = computed(() =>
|
||||
canWriteFiles.value
|
||||
? 'This button only works with compatible SFTP clients (e.g. WinSCP)'
|
||||
: permissionDeniedMessage.value,
|
||||
)
|
||||
const sftpCopyTooltip = (label: string) =>
|
||||
canWriteFiles.value ? label : permissionDeniedMessage.value
|
||||
|
||||
function handleSftpLaunchClick(event: MouseEvent) {
|
||||
if (canWriteFiles.value) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const copyToClipboard = (name: string, textToCopy?: string) => {
|
||||
if (!canWriteFiles.value) return
|
||||
navigator.clipboard.writeText(textToCopy || '')
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -242,6 +281,11 @@ const { data: startupData, isLoading: isStartupLoading } = useQuery({
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
function togglePasswordVisibility() {
|
||||
if (!canWriteFiles.value) return
|
||||
showPassword.value = !showPassword.value
|
||||
}
|
||||
|
||||
const JAVA_VERSIONS = [
|
||||
{ value: 8, label: 'Java 8' },
|
||||
{ value: 11, label: 'Java 11' },
|
||||
@@ -343,7 +387,7 @@ const hasUnsavedChanges = computed(
|
||||
jreVendor.value !== savedJreVendor.value,
|
||||
)
|
||||
|
||||
const { mutate: saveStartup, isPending } = useMutation({
|
||||
const { mutate: saveStartupMutation, isPending } = useMutation({
|
||||
mutationFn: () =>
|
||||
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
|
||||
startup_command: startupCommand.value || null,
|
||||
@@ -369,11 +413,17 @@ const { mutate: saveStartup, isPending } = useMutation({
|
||||
},
|
||||
})
|
||||
|
||||
function saveStartup() {
|
||||
if (!canUseAdvancedSettings.value) return
|
||||
saveStartupMutation()
|
||||
}
|
||||
|
||||
function resetStartup() {
|
||||
syncFormFromData()
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
if (!canUseAdvancedSettings.value) return
|
||||
startupCommand.value = defaultStartupCommand.value
|
||||
}
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user