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(() => {
|
||||
|
||||
Reference in New Issue
Block a user