feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 16:58:01 +01:00
committed by GitHub
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
@@ -26,8 +26,9 @@
>
<StyledInput
v-model="commandInput"
v-tooltip="disableInput ? disableInputTooltip : undefined"
:icon="TerminalSquareIcon"
:placeholder="disableInput ? 'Server is not running' : 'Send a command'"
:placeholder="disableInput ? disabledInputPlaceholder : 'Send a command'"
:disabled="disableInput"
wrapper-class="w-full"
input-class="!h-10"
@@ -51,6 +52,8 @@ const props = withDefaults(
scrollback?: number
showInput?: boolean
disableInput?: boolean
disableInputTooltip?: string
disabledInputPlaceholder?: string
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
loading?: boolean
@@ -59,6 +62,8 @@ const props = withDefaults(
scrollback: Infinity,
showInput: false,
disableInput: false,
disableInputTooltip: undefined,
disabledInputPlaceholder: 'Server is not running',
fullscreen: false,
emptyStateType: undefined,
loading: false,
@@ -215,6 +220,7 @@ watch(
)
const submitCommand = () => {
if (props.disableInput) return
const cmd = commandInput.value.trim()
if (!cmd) return
emit('command', cmd)
@@ -349,20 +349,19 @@ const fontSize = computed(() => {
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child,
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child > svg,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child {
> svg {
display: block;
width: var(--_icon-size, 1rem);
height: var(--_icon-size, 1rem);
min-width: var(--_icon-size, 1rem);
min-height: var(--_icon-size, 1rem);
}
+38 -7
View File
@@ -23,6 +23,10 @@
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
:autocomplete="searchAutocomplete"
:autocorrect="searchAutocorrect"
:autocapitalize="searchAutocapitalize"
:spellcheck="searchSpellcheck"
wrapper-class="w-full !bg-transparent"
:input-class="searchableInputClass"
class="relative z-[1]"
@@ -73,7 +77,7 @@
<slot name="selected" :label="triggerText">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<div class="flex shrink-0 items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
@@ -97,6 +101,7 @@
:class="[
props.dropdownClass,
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
props.dropdownClass,
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@@ -174,7 +179,7 @@
</div>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
<div v-else-if="searchQuery" class="p-4 text-center text-sm text-secondary">
{{ noOptionsMessage }}
</div>
@@ -276,12 +281,19 @@ const props = withDefaults(
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
dropdownClass?: string
dropdownMinWidth?: string
minSearchLengthToOpen?: number
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
/** Select the searchable input text when the field receives focus */
selectSearchTextOnFocus?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
searchAutocomplete?: string
searchAutocorrect?: 'on' | 'off'
searchAutocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
searchSpellcheck?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -293,6 +305,7 @@ const props = withDefaults(
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
noOptionsMessage: 'No results found',
minSearchLengthToOpen: 0,
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
@@ -377,6 +390,10 @@ const triggerText = computed(() => {
return props.placeholder
})
const hasMinimumSearchLength = computed(
() => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen,
)
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
@@ -413,8 +430,7 @@ function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: num
item.class,
{
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
isSelected,
'bg-highlight-green text-green hover:bg-highlight-green focus:bg-highlight-green': isSelected,
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
@@ -583,7 +599,8 @@ function destroyOptionsOverlayScrollbars() {
}
async function openDropdown() {
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
if (props.disabled || isOpen.value || !hasMinimumSearchLength.value || !hasDropdownContent.value)
return
isOpen.value = true
emit('open')
@@ -628,7 +645,11 @@ function handleTriggerClick(event: MouseEvent) {
function handleOptionClick(option: ComboboxOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
const isSelected = props.listbox && option.value === props.modelValue
if (isSelected) return
if (isSelected) {
focusedIndex.value = index
if (option.type !== 'link') closeDropdown()
return
}
focusedIndex.value = index
@@ -782,6 +803,10 @@ function handleSearchKeydown(event: KeyboardEvent) {
function handleSearchInput() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
if (!hasMinimumSearchLength.value) {
closeDropdown()
return
}
if (!isOpen.value) {
openDropdown()
}
@@ -906,10 +931,16 @@ watch(hasDropdownContent, (value) => {
}
})
watch(hasMinimumSearchLength, (canOpen) => {
if (!canOpen) {
closeDropdown()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {
if (props.searchable && props.syncWithSelection && !isOpen.value) {
if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) {
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
@@ -109,7 +109,7 @@
<button
ref="addMenuTrigger"
type="button"
:class="addButtonClass"
:class="addButtonClass ?? '!border'"
:aria-expanded="isAddMenuOpen"
aria-haspopup="menu"
@click="handleAddMenuTriggerClick"
@@ -262,58 +262,82 @@
:style="activeCategoryOptionsListStyle"
>
<div
v-for="{ option, index } in renderedVisibleActiveCategoryOptions"
:key="`${activeCategory.key}-${option.value}`"
v-for="{ item, index } in renderedVisibleActiveCategoryOptions"
:key="getActiveCategoryItemKey(item, index)"
:class="shouldVirtualizeActiveCategoryOptions ? 'absolute left-0 right-0' : undefined"
:style="getActiveCategoryOptionWrapperStyle(index)"
>
<div
v-if="isDropdownFilterSectionHeader(item)"
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-semibold text-secondary"
:class="item.class"
>
<span class="flex min-w-0 items-center gap-2">
<component
:is="item.icon"
v-if="item.icon"
class="size-4 shrink-0"
aria-hidden="true"
/>
<span class="min-w-0 truncate">{{ item.label }}</span>
</span>
<button
v-if="hasSelectableSectionHeaderOptions(item)"
type="button"
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:text-contrast"
@click="toggleSectionHeaderOptions(item)"
>
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
</button>
</div>
<button
v-else
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 border-0 px-4 py-3.5 text-left text-contrast shadow-none transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%] focus-visible:outline-none"
:class="[
shouldVirtualizeActiveCategoryOptions ? 'h-12' : undefined,
{
'brightness-[115%]': option.selected,
'pointer-events-none cursor-not-allowed opacity-50': option.disabled,
'brightness-[115%]': item.selected,
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
},
]"
:aria-disabled="option.disabled || undefined"
:aria-checked="option.selected"
:aria-disabled="item.disabled || undefined"
:aria-checked="item.selected"
role="checkbox"
@click="toggleFilterOption(activeCategory.key, option)"
@click="toggleFilterOption(activeCategory.key, item)"
>
<span
v-if="checkboxPosition === 'left'"
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
:class="
option.selected
item.selected
? 'border-button-border bg-brand text-brand-inverted'
: 'border-surface-5 bg-surface-2'
"
>
<CheckIcon v-if="option.selected" aria-hidden="true" stroke-width="3" />
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
</span>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<slot
v-if="$slots.option"
name="option"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
:index="index"
></slot>
<template v-else>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="option.selected ? 'text-contrast' : 'text-primary'"
:class="item.selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
{{ item.label }}
</span>
<slot
name="option-right"
:category="activeCategory"
:option="option"
:selected="option.selected"
:option="item"
:selected="item.selected"
></slot>
</template>
</div>
@@ -321,7 +345,7 @@
v-if="checkboxPosition === 'right'"
class="flex shrink-0 items-center justify-center text-brand"
>
<CheckIcon v-if="option.selected" aria-hidden="true" class="size-5" />
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
</span>
</button>
</div>
@@ -353,12 +377,12 @@ import {
} from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import type { ComponentPublicInstance, CSSProperties } from 'vue'
import type { Component, ComponentPublicInstance, CSSProperties } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import ButtonStyled from './ButtonStyled.vue'
import MultiSelect, { type MultiSelectOption } from './MultiSelect.vue'
import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue'
import StyledInput from './StyledInput.vue'
export type DropdownFilterBarOption = {
@@ -368,10 +392,20 @@ export type DropdownFilterBarOption = {
disabled?: boolean
}
export type DropdownFilterBarSectionHeader = {
type: 'section-header'
label: string
key?: string
icon?: Component
class?: string
}
export type DropdownFilterBarItem = DropdownFilterBarOption | DropdownFilterBarSectionHeader
export type DropdownFilterBarCategory = {
key: string
label: string
options: DropdownFilterBarOption[]
options: DropdownFilterBarItem[]
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
searchPlaceholder?: string
@@ -388,8 +422,12 @@ type RenderedDropdownFilterBarOption = DropdownFilterBarOption & {
selected: boolean
}
type RenderedDropdownFilterBarItem =
| RenderedDropdownFilterBarOption
| DropdownFilterBarSectionHeader
type VisibleDropdownFilterBarOption = {
option: RenderedDropdownFilterBarOption
item: RenderedDropdownFilterBarItem
index: number
}
@@ -464,6 +502,7 @@ const props = withDefaults(
showClear?: boolean
showLabel?: boolean
useFilterIcon?: boolean
applyImmediately?: boolean
showPreviewFilterIcon?: boolean
previewTriggerClass?: string
addButtonClass?: string
@@ -478,6 +517,7 @@ const props = withDefaults(
showClear: false,
showLabel: true,
useFilterIcon: false,
applyImmediately: false,
showPreviewFilterIcon: false,
emptyOptionsLabel: 'No options available.',
emptySearchLabel: 'No options found.',
@@ -519,6 +559,18 @@ let pendingCategoryTimeout: ReturnType<typeof setTimeout> | null = null
let previousMousePosition: Point | null = null
let addMenuPositionRafId: number | null = null
function isDropdownFilterSectionHeader(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarSectionHeader {
return 'type' in item && item.type === 'section-header'
}
function isDropdownFilterOption(
item: DropdownFilterBarItem | RenderedDropdownFilterBarItem,
): item is DropdownFilterBarOption {
return !isDropdownFilterSectionHeader(item)
}
const filterCategories = computed<DropdownFilterBarCategory[]>(() => {
const source = isAddMenuOpen.value ? 'draft' : 'committed'
return props.categories.map((category) => {
@@ -569,15 +621,32 @@ const filteredActiveCategoryOptions = computed(() => {
return activeCategory.value.options
}
return activeCategory.value.options.filter((option) => {
if (option.label.toLowerCase().includes(query)) {
return true
const items: DropdownFilterBarItem[] = []
let pendingSectionHeader: DropdownFilterBarSectionHeader | null = null
for (const option of activeCategory.value.options) {
if (isDropdownFilterSectionHeader(option)) {
pendingSectionHeader = option
continue
}
if (option.value.toLowerCase().includes(query)) {
return true
const matches =
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query) ||
(option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false)
if (!matches) {
continue
}
return option.searchTerms?.some((term) => term.toLowerCase().includes(query)) ?? false
})
if (pendingSectionHeader) {
items.push(pendingSectionHeader)
pendingSectionHeader = null
}
items.push(option)
}
return items
})
const shouldVirtualizeActiveCategoryOptions = computed(
@@ -599,11 +668,13 @@ const {
})
const renderedVisibleActiveCategoryOptions = computed<VisibleDropdownFilterBarOption[]>(() =>
visibleActiveCategoryOptions.value.map((option, offset) => ({
option: {
...option,
selected: activeCategorySelectedValueSet.value.has(option.value),
},
visibleActiveCategoryOptions.value.map((item, offset) => ({
item: isDropdownFilterSectionHeader(item)
? item
: {
...item,
selected: activeCategorySelectedValueSet.value.has(item.value),
},
index: activeCategoryOptionsVisibleRange.value.start + offset,
})),
)
@@ -721,14 +792,14 @@ function areSelectedFiltersEqual(
}
function getOptionsWithSelectedValues(
options: DropdownFilterBarOption[],
options: DropdownFilterBarItem[],
selectedValues: string[],
): DropdownFilterBarOption[] {
): DropdownFilterBarItem[] {
if (selectedValues.length === 0) {
return options
}
const knownValues = new Set(options.map((option) => option.value))
const knownValues = new Set(options.filter(isDropdownFilterOption).map((option) => option.value))
const missingSelectedOptions = selectedValues
.filter((value) => !knownValues.has(value))
.map((value) => ({
@@ -748,15 +819,17 @@ function getCategorySyntheticValues(categoryKey: string): Set<string> {
return category ? getCategorySyntheticValueSet(category) : new Set()
}
function getVisiblePreviewOptions(
category: DropdownFilterBarCategory,
): MultiSelectOption<string>[] {
return category.options.map((option) => ({
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
})) as MultiSelectOption<string>[]
function getVisiblePreviewOptions(category: DropdownFilterBarCategory): MultiSelectItem<string>[] {
return category.options.map((option) =>
isDropdownFilterSectionHeader(option)
? option
: {
value: option.value,
label: option.label,
searchTerms: option.searchTerms,
disabled: option.disabled,
},
) as MultiSelectItem<string>[]
}
function getPreviewOptionLabel(
@@ -764,7 +837,7 @@ function getPreviewOptionLabel(
selectedValue: string,
): string | undefined {
return [...(category.syntheticOptions ?? []), ...category.options].find(
(option) => option.value === selectedValue,
(option) => isDropdownFilterOption(option) && option.value === selectedValue,
)?.label
}
@@ -797,6 +870,9 @@ function setSelectedValues(
if (isAddMenuOpen.value && activeCategoryKey.value === categoryKey) {
scheduleSubmenuPositionUpdate()
}
if (props.applyImmediately) {
emit('update:modelValue', nextFilters)
}
} else {
emit('update:modelValue', nextFilters)
}
@@ -950,6 +1026,70 @@ function toggleFilterOption(categoryKey: string, option: DropdownFilterBarOption
toggleFilterValue(categoryKey, option.value, !isFilterValueSelected(categoryKey, option.value))
}
function getActiveCategoryItemKey(item: RenderedDropdownFilterBarItem, index: number) {
return isDropdownFilterSectionHeader(item)
? (item.key ?? `${activeCategory.value?.key ?? 'category'}-section-${item.label}-${index}`)
: `${activeCategory.value?.key ?? 'category'}-${item.value}`
}
function getSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return []
}
const sectionHeaderIndex = category.options.findIndex((item) => item === sectionHeader)
if (sectionHeaderIndex === -1) {
return []
}
const options: DropdownFilterBarOption[] = []
for (let index = sectionHeaderIndex + 1; index < category.options.length; index += 1) {
const item = category.options[index]
if (!item || isDropdownFilterSectionHeader(item)) {
break
}
if (!item.disabled) {
options.push(item)
}
}
return options
}
function hasSelectableSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
return getSectionHeaderOptions(sectionHeader).length > 1
}
function areSectionHeaderOptionsSelected(sectionHeader: DropdownFilterBarSectionHeader) {
const options = getSectionHeaderOptions(sectionHeader)
return (
options.length > 0 &&
options.every((option) => activeCategorySelectedValueSet.value.has(option.value))
)
}
function toggleSectionHeaderOptions(sectionHeader: DropdownFilterBarSectionHeader) {
const category = activeCategory.value
if (!category) {
return
}
const options = getSectionHeaderOptions(sectionHeader)
if (options.length === 0) {
return
}
const optionValues = options.map((option) => option.value)
const optionValueSet = new Set(optionValues)
const currentValues = activeCategorySelectedValues.value
const nextValues = areSectionHeaderOptionsSelected(sectionHeader)
? currentValues.filter((value) => !optionValueSet.has(value))
: [...currentValues, ...optionValues.filter((value) => !currentValues.includes(value))]
setSelectedValues(category.key, nextValues, 'draft')
}
function getActiveCategoryOptionWrapperStyle(index: number): CSSProperties | undefined {
if (!shouldVirtualizeActiveCategoryOptions.value) {
return undefined
@@ -1076,9 +1216,13 @@ function getPreviewSelectedValues(categoryKey: string): string[] {
}
function setPreviewSelectedValues(categoryKey: string, values: string[]) {
const normalizedValues = normalizeSelectedValues(values)
previewSelectedValueDrafts.value = {
...previewSelectedValueDrafts.value,
[categoryKey]: normalizeSelectedValues(values),
[categoryKey]: normalizedValues,
}
if (props.applyImmediately) {
setSelectedValues(categoryKey, normalizedValues)
}
}
@@ -31,11 +31,15 @@ const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCo
const floatingActionBarId = Symbol('floating-action-bar')
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
() => pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)',
const leftOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)'),
)
const rightOffset = computed(
() => pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)',
const rightOffset = computed(() =>
stackCount.value > 0
? '0px'
: (pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)'),
)
const barStyle = computed(() => ({
zIndex: zIndex.value,
@@ -2,6 +2,7 @@
<div class="joined-buttons">
<ButtonStyled :color="color" :size="size">
<button
v-tooltip="primaryTooltip"
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
:disabled="primaryDisabledResolved"
@click="handlePrimaryAction"
@@ -15,6 +16,7 @@
class="btn-dropdown-animation !w-10"
:options="dropdownOptions"
:disabled="dropdownDisabledResolved"
:tooltip="dropdownTooltip"
>
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
@@ -53,6 +55,8 @@ interface Props {
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
primaryTooltip?: string
dropdownTooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -62,6 +66,8 @@ const props = withDefaults(defineProps<Props>(), {
primaryDisabled: undefined,
dropdownDisabled: undefined,
primaryMuted: false,
primaryTooltip: undefined,
dropdownTooltip: undefined,
})
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
@@ -74,6 +74,7 @@
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
v-tooltip="leftButtonConfig.tooltip"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
@@ -84,6 +85,7 @@
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
v-tooltip="rightButtonConfig.tooltip"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@@ -128,6 +130,7 @@ export interface StageButtonConfig {
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
loading?: boolean
tooltip?: string
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
@@ -28,6 +28,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:maxlength="maxlength"
:rows="rows"
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
@@ -55,6 +58,9 @@
:readonly="readonly"
:name="name"
:autocomplete="autocomplete"
:autocorrect="autocorrect"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
:inputmode="inputmode"
:maxlength="maxlength"
:min="min"
@@ -124,6 +130,9 @@ const props = withDefaults(
id?: string
name?: string
autocomplete?: string
autocorrect?: 'on' | 'off'
autocapitalize?: 'none' | 'off' | 'sentences' | 'words' | 'characters'
spellcheck?: boolean
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
maxlength?: number
min?: number
+56 -2
View File
@@ -62,7 +62,56 @@
</th>
</tr>
</thead>
<tbody :ref="setListContainer">
<TransitionGroup
v-if="rowTransitionName && !virtualized"
:name="rowTransitionName"
tag="tbody"
>
<tr v-if="data.length === 0" key="empty" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
<div class="text-secondary flex h-64 items-center justify-center">
No data available.
</div>
</slot>
</td>
</tr>
<template v-else>
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
>
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="getAbsoluteRowIndex(rowIndex)"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
</template>
</TransitionGroup>
<tbody v-else :ref="setListContainer">
<tr v-if="data.length === 0" class="bg-surface-2">
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
<slot name="empty-state">
@@ -83,7 +132,7 @@
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
>
<td
v-if="showSelection"
@@ -169,6 +218,7 @@ const props = withDefaults(
virtualized?: boolean
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
rowTransitionName?: string
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
@@ -269,6 +319,10 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
return rowIndex
}
function getRowClass(rowIndex: number): string {
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function isSelected(row: T): boolean {
return selectedIdSet.value.has(getSelectionId(row))
}
@@ -96,6 +96,14 @@ const { data: regionsData } = useQuery({
queryFn: () => archon.servers_v1.getRegions(),
})
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
watch(
customerData,
(newCustomer) => {
@@ -136,14 +144,6 @@ async function fetchStock(
return result.available
}
const PING_COUNT = 20
const PING_INTERVAL = 200
const MAX_PING_TIME = 1000
const initialIndex = {
'eu-lim': 31,
}
function runPingTest(
region: Archon.Servers.v1.Region,
index = initialIndex[region.shortcode] ?? 1,
@@ -5,8 +5,10 @@
}}</span>
<Combobox
v-model="ctx.modpackSearchProjectId.value"
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
:options="ctx.modpackSearchOptions.value"
searchable
:disabled="ctx.finishDisabled.value"
:search-placeholder="formatMessage(messages.searchModpackPlaceholder)"
:no-options-message="
searchLoading
@@ -29,13 +31,23 @@
</div>
<div class="flex gap-3">
<ButtonStyled type="outlined">
<button class="flex-1" @click="triggerFileInput">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="triggerFileInput"
>
<ImportIcon />
{{ formatMessage(messages.importModpack) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()">
<button
v-tooltip="ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined"
class="flex-1"
:disabled="ctx.finishDisabled.value"
@click="ctx.browseModpacks()"
>
<CompassIcon />
{{ formatMessage(messages.browseModpacks) }}
</button>
@@ -87,6 +99,8 @@ const messages = defineMessages({
})
function proceedWithModpack() {
if (ctx.finishDisabled.value) return
debug('proceedWithModpack:', {
flowType: ctx.flowType,
modpackSelection: ctx.modpackSelection.value,
@@ -196,6 +210,8 @@ watch(
)
async function triggerFileInput() {
if (ctx.finishDisabled.value) return
const picked = await filePicker.pickModpackFile({
readFile: ctx.flowType !== 'instance',
})
@@ -186,6 +186,8 @@ export interface CreationFlowContextValue {
// Loading state (set when finish() is called, cleared on reset)
loading: Ref<boolean>
finishDisabled: ComputedRef<boolean>
finishDisabledTooltip: ComputedRef<string | undefined>
// Backup state (set by InlineBackupCreator in reset-server flow)
isBackingUp: Ref<boolean>
@@ -232,6 +234,8 @@ export interface CreationFlowOptions {
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
finishDisabled?: ComputedRef<boolean>
finishDisabledTooltip?: ComputedRef<string | undefined>
}
export function createCreationFlowContext(
@@ -257,6 +261,8 @@ export function createCreationFlowContext(
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const getLoaderManifest = options.getLoaderManifest ?? null
const finishDisabled = options.finishDisabled ?? computed(() => false)
const finishDisabledTooltip = options.finishDisabledTooltip ?? computed(() => undefined)
const setupType = ref<SetupType | null>(null)
const isImportMode = ref(false)
@@ -502,6 +508,8 @@ export function createCreationFlowContext(
}
function finish() {
if (finishDisabled.value) return
debug('finish() called, state:', {
setupType: setupType.value,
selectedLoader: selectedLoader.value,
@@ -585,6 +593,8 @@ export function createCreationFlowContext(
importSearchQuery,
hardReset,
loading,
finishDisabled,
finishDisabledTooltip,
isBackingUp,
cancelBackup,
modal,
@@ -10,7 +10,7 @@
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import MultiStageModal from '../../base/MultiStageModal.vue'
@@ -38,6 +38,8 @@ const props = withDefaults(
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
finishDisabled?: boolean
finishDisabledTooltip?: string
}>(),
{
type: 'world',
@@ -78,6 +80,8 @@ const ctx = createCreationFlowContext(
searchModpacks: props.searchModpacks,
getProjectVersions: props.getProjectVersions,
getLoaderManifest: props.getLoaderManifest,
finishDisabled: computed(() => props.finishDisabled ?? false),
finishDisabledTooltip: computed(() => props.finishDisabledTooltip),
},
)
provideCreationFlowContext(ctx)
@@ -46,12 +46,14 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: PlusIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled,
disabled: disabled || ctx.finishDisabled.value,
loading: ctx.loading.value,
tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => ctx.finish(),
}
}
const finishDisabled = !goesToNextStage && ctx.finishDisabled.value
return {
label: ctx.formatMessage(
goesToNextStage ? commonMessages.continueButton : creationFlowMessages.finishButton,
@@ -59,7 +61,8 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: goesToNextStage ? RightArrowIcon : null,
iconPosition: 'after' as const,
color: goesToNextStage ? undefined : ('brand' as const),
disabled,
disabled: disabled || finishDisabled,
tooltip: finishDisabled ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => {
if (goesToNextStage) {
ctx.modal.value?.nextStage()
@@ -51,8 +51,10 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value,
disabled:
isForwardBlocked(ctx) || ctx.isBackingUp.value || (isFinish && ctx.finishDisabled.value),
loading: isFinish && ctx.loading.value,
tooltip: isFinish && ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => {
if (isFinish) {
ctx.finish()
@@ -38,7 +38,8 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: DownloadIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled: count === 0,
disabled: count === 0 || ctx.finishDisabled.value,
tooltip: ctx.finishDisabled.value ? ctx.finishDisabledTooltip.value : undefined,
onClick: () => ctx.finish(),
}
},
+1
View File
@@ -8,6 +8,7 @@ export * from './content'
export * from './external_files'
export * from './modal'
export * from './nav'
export * from './notifications'
export * from './page'
export * from './project'
export * from './search'
+93 -1
View File
@@ -136,6 +136,7 @@
import { XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useDebugLogger } from '../../composables/debug-logger'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
@@ -144,6 +145,7 @@ import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('NewModal')
const modalBehavior = injectModalBehavior(null)
const {
@@ -233,54 +235,138 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
debug('show: start', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
hasEvent: !!event,
})
props.onShow?.()
debug('show: after onShow', { header: props.header })
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
debug('show: before open=true', {
header: props.header,
wasEmpty,
stackDepth: stackDepth.value,
})
open.value = true
debug('show: after open=true', {
header: props.header,
open: open.value,
modalBodyExists: !!modalBodyRef.value,
})
previousFocusEl = document.activeElement
debug('show: previous focus captured', {
header: props.header,
previousFocusTag: previousFocusEl instanceof HTMLElement ? previousFocusEl.tagName : null,
previousFocusClass: previousFocusEl instanceof HTMLElement ? previousFocusEl.className : null,
})
pushModal()
debug('show: after pushModal', { header: props.header, stackSize: modalStackSize() })
if (wasEmpty) modalBehavior?.onShow?.()
debug('show: after modalBehavior onShow', { header: props.header })
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleWindowKeyDown)
window.addEventListener('mousedown', updateMousePosition)
debug('show: listeners attached', { header: props.header })
if (event) {
updateMousePosition(event)
} else {
mouseX.value = Math.round(window.innerWidth / 2)
mouseY.value = Math.round(window.innerHeight / 2)
}
debug('show: mouse position set', {
header: props.header,
mouseX: mouseX.value,
mouseY: mouseY.value,
})
setTimeout(() => {
debug('show: timeout before visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
visible.value = true
debug('show: timeout after visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
nextTick(() => {
debug('show: nextTick focus start', {
header: props.header,
modalBodyExists: !!modalBodyRef.value,
})
const focusable = getFocusableElements()
debug('show: focusable elements', {
header: props.header,
count: focusable.length,
firstTag: focusable[0]?.tagName,
})
if (focusable.length > 0) {
focusable[0].focus()
} else {
modalBodyRef.value?.focus()
}
debug('show: nextTick focus done', { header: props.header })
})
}, 50)
debug('show: end', { header: props.header })
}
function hide() {
if (props.disableClose) return
debug('hide: start', {
header: props.header,
open: open.value,
visible: visible.value,
disableClose: props.disableClose,
stackSize: modalStackSize(),
})
if (props.disableClose) {
debug('hide: ignored disableClose', { header: props.header })
return
}
props.onHide?.()
debug('hide: after onHide', { header: props.header })
visible.value = false
debug('hide: after visible=false', { header: props.header, visible: visible.value })
popModal()
debug('hide: after popModal', { header: props.header, stackSize: modalStackSize() })
if (modalStackSize() === 0) {
modalBehavior?.onHide?.()
document.body.style.overflow = ''
}
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
debug('hide: listeners removed', { header: props.header })
if (previousFocusEl instanceof HTMLElement) {
debug('hide: restoring focus', {
header: props.header,
previousFocusTag: previousFocusEl.tagName,
previousFocusClass: previousFocusEl.className,
})
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
debug('hide: timeout before open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
open.value = false
debug('hide: timeout after open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
}, 300)
debug('hide: end', { header: props.header })
}
defineExpose({
@@ -308,6 +394,12 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
}
onUnmounted(() => {
debug('unmounted', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
})
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
@@ -13,7 +13,38 @@
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<NotificationToast
v-if="item.toast"
:type="item.toast.type"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
:entity-icon-url="item.toast.entityIconUrl"
:status-text="item.toast.statusText"
:progress="item.toast.progress"
:waiting="item.toast.waiting"
@accept="handleToastAction(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@open-actor="item.toast.onOpenActor?.()"
@open-instance="handleToastAction(item, item.toast.onOpenInstance)"
/>
<div v-else-if="isDownloadNotification(item)" class="flex flex-col gap-4">
<NotificationToast
v-for="progressItem in downloadToastItems(item)"
:key="progressItem.id"
type="instance-download"
:entity-name="progressItem.title || item.title"
:entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon"
:status-text="downloadStatusText(progressItem)"
:progress="progressItem.progress"
:waiting="progressItem.waiting"
@dismiss="dismiss(item.id)"
/>
</div>
<div
v-else
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
>
<div class="flex flex-col gap-2 w-full">
@@ -118,6 +149,7 @@ import {
DownloadIcon,
InfoIcon,
IssuesIcon,
MinecraftServerIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
@@ -127,9 +159,11 @@ import {
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationButton,
type PopupNotificationProgressItem,
} from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue'
import NotificationToast from '../notifications/NotificationToast.vue'
const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() =>
@@ -141,6 +175,34 @@ const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
function isDownloadNotification(item: PopupNotification) {
return (
item.type === 'download' &&
(!!item.progressItems?.length || item.progress != null || item.waiting)
)
}
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
if (item.progressItems?.length) {
return item.progressItems
}
return [
{
id: `${item.id}`,
title: item.title,
text: item.text,
iconUrl: item.iconUrl,
progress: item.progress ?? 0,
waiting: item.waiting ?? false,
},
]
}
function downloadStatusText(progressItem: PopupNotificationProgressItem) {
return progressItem.text?.replace(/^\d+%\s*/, '') ?? ''
}
function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
btn.action()
if (!btn.keepOpen) {
@@ -148,6 +210,11 @@ function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
}
}
async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) {
popupNotificationManager.removeNotification(item.id)
await action?.()
}
function progressColorForType(type: PopupNotification['type']) {
if (type === 'error') {
return 'red'
@@ -179,8 +246,9 @@ withDefaults(
top: calc(var(--top-bar-height, 3rem) + 1.5rem);
right: 1.5rem;
z-index: 200;
width: 520px;
max-width: calc(100vw - 3rem);
width: min(420px, calc(100vw - 1.5rem));
min-width: min(420px, calc(100vw - 1.5rem));
max-width: min(420px, calc(100vw - 1.5rem));
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -192,8 +260,6 @@ withDefaults(
@media screen and (max-width: 500px) {
.popup-notification-group {
width: calc(100% - 1.5rem);
max-width: none;
right: 0.75rem;
}
}
@@ -0,0 +1,61 @@
<template>
<div
class="notification-stack"
:class="{
'has-sidebar': hasSidebar,
}"
>
<TransitionGroup name="notification-stack-item" tag="div" class="notification-stack-items">
<slot />
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
hasSidebar?: boolean
}>(),
{
hasSidebar: false,
},
)
</script>
<style scoped>
.notification-stack {
position: fixed;
top: calc(var(--top-bar-height, 3rem) + 1rem);
right: 1rem;
z-index: 200;
width: min(420px, calc(100vw - 1.5rem));
}
.notification-stack.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1rem);
}
@media screen and (max-width: 500px) {
.notification-stack {
right: 0.75rem;
}
}
.notification-stack-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
:global(.notification-stack-item-enter-active),
:global(.notification-stack-item-leave-active),
:global(.notification-stack-item-move) {
transition: all 0.3s ease-in-out;
}
:global(.notification-stack-item-enter-from),
:global(.notification-stack-item-leave-to) {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
</style>
@@ -0,0 +1,273 @@
<template>
<div
class="notification-toast relative overflow-hidden rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-4 shadow-[0px_4px_8px_0px_rgba(0,0,0,0.1),0px_1px_3px_0px_rgba(0,0,0,0.2)]"
>
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
<Avatar
:src="actorAvatarUrl"
:alt="actorLabel"
:tint-by="actorLabel"
size="44px"
circle
no-shadow
class="border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col gap-2.5">
<div class="flex w-full items-start gap-1">
<p class="m-0 min-w-0 flex-1 break-words text-lg font-normal leading-6 text-contrast/85">
<template v-if="type === 'friend-request'">
<span class="font-semibold text-contrast">{{ actorLabel }}</span>
<span> sent you a friend request.</span>
</template>
<template v-else>
<button
v-if="actorName"
type="button"
class="m-0 inline border-0 bg-transparent p-0 text-lg font-semibold leading-6 text-contrast hover:underline"
@click="$emit('open-actor')"
>
{{ actorName }}
</button>
<span v-else class="font-semibold text-contrast">Someone</span>
<span class="mx-1">{{ inviteActionText }}</span>
<template v-if="type === 'server-invite'">
<span class="font-semibold text-contrast">{{ entityLabel }}</span
>.
</template>
<template v-else>
<span class="inline-flex max-w-full items-center gap-[5px] align-[-4px]">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="24px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-[7px]"
/>
<span class="min-w-0 truncate font-semibold text-contrast">{{
entityLabel
}}</span> </span
>.
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('accept')">Accept</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('decline')">Decline</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="flex w-full items-start gap-3">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="44px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-xl border border-solid border-surface-5"
/>
<div class="flex min-w-0 flex-1 flex-col" :class="{ 'gap-2.5': type === 'instance-ready' }">
<div class="flex min-w-0 flex-1 items-start gap-1">
<div class="flex min-w-0 flex-1 flex-col gap-[3px] text-base leading-5">
<p
ref="titleRef"
v-tooltip="truncatedTooltip(titleRef, entityLabel)"
class="m-0 min-w-0 truncate text-lg font-semibold leading-6 text-contrast"
>
{{ entityLabel }}
</p>
<p
ref="statusRef"
v-tooltip="truncatedTooltip(statusRef, statusLine)"
class="m-0 min-w-0 truncate font-normal leading-tight text-contrast/85"
>
{{ statusLine }}
</p>
</div>
<ButtonStyled size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
aria-label="Dismiss notification"
@click="$emit('dismiss')"
>
<XIcon />
</button>
</ButtonStyled>
</div>
<div v-if="type === 'instance-ready'" class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('launch')">Launch game</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('open-instance')">Instance</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="showsBottomProgress"
class="notification-bottom-progress-track absolute inset-x-0 bottom-0 h-1 overflow-hidden"
role="progressbar"
:aria-valuenow="isWaitingProgress ? undefined : progressPercent"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full transition-[left,width] duration-200 ease-in-out"
:class="[
type === 'instance-ready' ? 'bg-surface-5' : 'bg-brand',
{ 'notification-bottom-progress--waiting': isWaitingProgress },
]"
:style="isWaitingProgress ? undefined : { width: `${progressPercent}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { truncatedTooltip } from '../../utils/truncate'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
type NotificationToastType =
| 'friend-request'
| 'server-invite'
| 'instance-invite'
| 'instance-download'
| 'instance-ready'
const props = withDefaults(
defineProps<{
type: NotificationToastType
actorName?: string | null
actorAvatarUrl?: string | null
entityName?: string
entityIconUrl?: string | null
statusText?: string
progress?: number
waiting?: boolean
}>(),
{
actorName: null,
actorAvatarUrl: null,
entityName: '',
entityIconUrl: null,
waiting: false,
},
)
defineEmits<{
accept: []
decline: []
dismiss: []
launch: []
'open-actor': []
'open-instance': []
}>()
const isInviteNotification = computed(
() =>
props.type === 'friend-request' ||
props.type === 'server-invite' ||
props.type === 'instance-invite',
)
const actorLabel = computed(() => props.actorName || 'Someone')
const entityLabel = computed(() => props.entityName || '')
const progressValue = computed(() => Math.max(0, Math.min(1, props.progress ?? 0)))
const progressPercent = computed(() => Math.round(progressValue.value * 100))
const isWaitingProgress = computed(() => props.type === 'instance-download' && props.waiting)
const inviteActionText = computed(() => {
if (props.type === 'server-invite') {
return 'invited you to manage the server'
}
return 'invited you to play the instance'
})
const resolvedStatusText = computed(() => {
if (props.type === 'instance-ready') {
return props.statusText ?? 'Installed and ready to play.'
}
return props.statusText ?? ''
})
const statusLine = computed(() => {
if (props.type !== 'instance-download' || props.waiting) {
return resolvedStatusText.value
}
const status = resolvedStatusText.value.trim()
return status ? `${status} ${progressPercent.value}%` : `${progressPercent.value}%`
})
const showsBottomProgress = computed(
() =>
props.type === 'instance-download' ||
(props.type === 'instance-ready' && props.progress != null),
)
const titleRef = ref<HTMLElement | null>(null)
const statusRef = ref<HTMLElement | null>(null)
</script>
<style scoped>
.notification-toast {
width: min(420px, calc(100vw - 1.5rem));
}
.notification-toast-dismiss {
--_height: 1.25rem;
--_width: 1.25rem;
--_padding-x: 0;
--_padding-y: 0;
--_icon-size: 1.25rem;
--_box-shadow: none;
--_text: var(--color-base);
--_hover-bg: transparent;
--_hover-text: var(--color-contrast);
}
.notification-bottom-progress--waiting {
animation: notification-bottom-progress-waiting 1s linear infinite;
position: relative;
width: 20%;
}
@keyframes notification-bottom-progress-waiting {
0% {
left: -20%;
}
100% {
left: 100%;
}
}
.notification-bottom-progress-track {
background-color: color-mix(in srgb, var(--surface-2) 50%, transparent);
}
</style>
@@ -0,0 +1,2 @@
export { default as NotificationStack } from './NotificationStack.vue'
export { default as NotificationToast } from './NotificationToast.vue'
@@ -28,7 +28,13 @@
</div>
<template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button class="!border" type="button" @click="emit('retry')">
<button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
class="!border"
type="button"
:disabled="retryDisabled"
@click="emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
@@ -62,6 +68,8 @@ const props = defineProps<{
fallbackPhase?: SyncProgress['phase'] | null
contentError?: ContentError | null
dismissible?: boolean
retryDisabled?: boolean
retryDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -35,10 +35,26 @@
</div>
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
<div class="ml-4 flex flex-col gap-1.5">
<div class="flex flex-row items-center gap-2">
<div class="flex flex-row items-center gap-2.5">
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
{{ name }}
</h2>
<div
v-if="owner"
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
class="flex min-w-0 items-center gap-1 rounded-full bg-surface-4 px-2 pr-2.5 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
:class="{ 'opacity-50': isDisabled }"
>
<Avatar
:src="owner.avatarUrl"
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
:tint-by="owner.username"
size="1.25rem"
circle
no-shadow
/>
<span class="max-w-32 truncate">{{ owner.username }}</span>
</div>
<div
v-if="isConfiguring && noticeType !== 'cancelled' && noticeType !== 'setToCancel'"
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
@@ -262,6 +278,7 @@ import { injectModrinthClient } from '../../providers/api-client'
import Avatar from '../base/Avatar.vue'
import IntlFormatted from '../base/IntlFormatted.vue'
import ServersSpecs from '../billing/ServersSpecs.vue'
import type { ServerListingOwner } from './access/types'
import ServerIcon from './icons/ServerIcon.vue'
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
@@ -281,6 +298,14 @@ const messages = defineMessages({
id: 'servers.listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
ownerTooltip: {
id: 'servers.listing.owner-tooltip',
defaultMessage: 'Owned by {username}',
},
ownerAvatarAlt: {
id: 'servers.listing.owner-avatar-alt',
defaultMessage: "{username}'s avatar",
},
provisioningNotice: {
id: 'servers.listing.notice.provisioning',
defaultMessage: 'Please wait while we set up your server. This can take up to 10 minutes.',
@@ -402,6 +427,7 @@ type ServerListingProps = {
cancellationDate?: string | Date | null
onResubscribe?: (() => void) | null
onDownloadBackup?: (() => void) | null
owner?: ServerListingOwner
}
const props = defineProps<ServerListingProps>()
@@ -60,13 +60,13 @@
<script setup lang="ts">
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useFormatBytes } from '#ui/composables'
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
import type { ServerStats } from '#ui/providers/server-context'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
@@ -82,7 +82,7 @@ const { featureFlags } = injectPageContext()
const props = withDefaults(
defineProps<{
data?: Stats
data?: ServerStats
loading?: boolean
showMemoryAsBytes?: boolean
}>(),
@@ -11,6 +11,8 @@
:fade="props.initialSetup ? undefined : 'danger'"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:finish-disabled="!canCompleteSetup"
:finish-disabled-tooltip="!canCompleteSetup ? permissionDeniedMessage : undefined"
@create="onFlowComplete"
@hide="$emit('hide')"
@browse-modpacks="$emit('browse-modpacks')"
@@ -24,6 +26,7 @@ import type { Archon, ModrinthApiError } from '@modrinth/api-client'
import { computed, useTemplateRef } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { useServerPermissions } from '#ui/composables/server-permissions'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers/api-client'
@@ -60,6 +63,7 @@ const serverContext = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const serverLoaders = ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']
const { canSetup, canResetServer, permissionDeniedMessage } = useServerPermissions()
async function searchModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
@@ -97,12 +101,20 @@ const initialLoader = computed(() => {
})
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
const canCompleteSetup = computed(() =>
props.initialSetup ? canSetup.value : canResetServer.value,
)
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
const uploadProgressModal =
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
async function onFlowComplete(ctx: CreationFlowContextValue) {
if (!canCompleteSetup.value) {
ctx.loading.value = false
return
}
debug('onFlowComplete:', {
setupType: ctx.setupType.value,
hasModpackFile: !!ctx.modpackFile.value,
@@ -207,6 +219,8 @@ function emitReinstall(args?: { loader: string; lVersion: string; mVersion: stri
}
function show() {
if (!canCompleteSetup.value) return
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('paper')
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('purpur')
creationFlowRef.value?.show()
@@ -0,0 +1,625 @@
<template>
<Table
v-if="members.length > 0"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
class="hidden sm:block"
:columns="columns"
:data="tableMembers"
row-key="id"
table-min-width="42rem"
>
<template #cell-user="{ row: member }">
<AutoLink
:to="userProfilePath(member.user.username)"
class="inline-flex max-w-full min-w-0 items-center gap-2"
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="member.user.avatarUrl"
:alt="formatMessage(messages.userAvatarAlt, { username: member.user.username })"
:tint-by="member.user.username"
size="22px"
circle
no-shadow
/>
<span class="min-w-0 truncate font-medium">
{{ member.user.username }}
</span>
</AutoLink>
</template>
<template #cell-role="{ row: member }">
<span
v-if="member.isOwner"
class="inline-flex h-7 items-center rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none"
:class="roleClasses(member.role)"
>
{{ formatRole(member.role) }}
</span>
<div v-else v-tooltip="accessManagementTooltip" class="w-fit">
<Combobox
:model-value="member.role"
:options="roleComboboxOptions"
:display-value="formatRole(member.role)"
:disabled="!canManageUsers"
:trigger-class="`${roleTriggerClass(member.role)} !inline-flex !w-auto !h-7 !min-h-0 !rounded-full !border !border-solid !px-2.5 !py-1 gap-1 !text-sm !font-semibold !leading-5`"
dropdown-class="!rounded-[24px] !bg-surface-3"
dropdown-min-width="18rem"
force-direction="down"
@update:model-value="(role) => handleUpdateRole(member, role)"
>
<template #selected>
<span class="font-semibold leading-5" :class="roleTextClass(member.role)">
{{ formatRole(member.role) }}
</span>
</template>
</Combobox>
</div>
</template>
<template #cell-joined="{ row: member }">
<span
v-if="member.pending"
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
{{ formatMessage(messages.pendingLabel) }}
</span>
<span v-else-if="member.joinedAt" v-tooltip="formatDate(member.joinedAt)">
{{ formatRelativeTime(member.joinedAt) }}
</span>
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
</template>
<template #cell-actions="{ row: member }">
<div v-if="!member.isOwner" class="flex items-center justify-end gap-1">
<ButtonStyled v-if="member.pending" circular type="transparent">
<button
v-tooltip="resendInviteTooltip(member)"
:aria-label="resendInviteLabel(member)"
:disabled="resendInviteDisabled(member)"
class="text-secondary hover:!filter-none hover:text-contrast focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="handleResendInvite(member)"
>
<SendIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button
v-tooltip="memberAccessActionTooltip(member)"
:aria-label="memberAccessActionLabel(member)"
:disabled="!canManageUsers"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
@click="member.pending ? handleCancelInvite(member) : handleRemoveMember(member)"
>
<XIcon v-if="member.pending" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
<div
v-if="members.length > 0"
class="overflow-hidden rounded-2xl border border-solid border-surface-5 sm:hidden"
>
<div
class="grid min-h-14 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] bg-surface-3"
>
<div class="flex items-center pl-4 font-semibold text-secondary">
<button
type="button"
class="flex min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
:class="sortColumn === 'user' ? 'text-contrast' : 'text-secondary'"
@click="toggleSort('user')"
>
<span class="min-w-0 truncate">{{ formatMessage(messages.userColumn) }}</span>
<component :is="sortIcon('user')" v-if="sortIcon('user')" class="size-4" />
</button>
</div>
<div class="flex items-center font-semibold text-secondary">
<button
type="button"
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
:class="sortColumn === 'role' ? 'text-contrast' : 'text-secondary'"
@click="toggleSort('role')"
>
{{ formatMessage(messages.roleColumn) }}
<component :is="sortIcon('role')" v-if="sortIcon('role')" class="size-4" />
</button>
</div>
<div class="flex items-center justify-end font-semibold text-secondary">
<button
type="button"
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
:class="sortColumn === 'joined' ? 'text-contrast' : 'text-secondary'"
@click="toggleSort('joined')"
>
{{ formatMessage(messages.joinedColumn) }}
<component :is="sortIcon('joined')" v-if="sortIcon('joined')" class="size-4" />
</button>
</div>
<div class="flex items-center justify-end pr-4 font-semibold text-secondary">
<span class="sr-only">{{ formatMessage(messages.actionsColumn) }}</span>
</div>
</div>
<div
v-for="(member, index) in sortedMembers"
:key="member.id"
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-5"
:class="index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<div class="flex min-w-0 items-center pl-4">
<AutoLink
v-tooltip="member.user.username"
:to="userProfilePath(member.user.username)"
class="inline-flex min-w-0 items-center gap-2"
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="member.user.avatarUrl"
:alt="formatMessage(messages.userAvatarAlt, { username: member.user.username })"
:tint-by="member.user.username"
size="24px"
circle
no-shadow
/>
<span class="min-w-0 truncate font-medium">
{{ member.user.username }}
</span>
</AutoLink>
</div>
<div class="min-w-0 py-3 pr-2">
<span
v-if="member.isOwner"
class="inline-flex h-7 max-w-full items-center truncate rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none"
:class="roleClasses(member.role)"
>
{{ formatRole(member.role) }}
</span>
<div v-else v-tooltip="accessManagementTooltip" class="min-w-0">
<Combobox
:model-value="member.role"
:options="roleComboboxOptions"
:display-value="formatRole(member.role)"
:disabled="!canManageUsers"
:trigger-class="
roleTriggerClass(member.role) +
` !inline-flex !w-auto !max-w-full !h-7 !min-h-0 !rounded-full !border !border-solid !px-2.5 !py-1 gap-1 !text-sm !font-semibold !leading-5`
"
dropdown-class="!rounded-[24px] !bg-surface-3"
dropdown-min-width="18rem"
force-direction="down"
@update:model-value="(role) => handleUpdateRole(member, role)"
>
<template #selected>
<span
class="min-w-0 truncate font-semibold leading-5"
:class="roleTextClass(member.role)"
>
{{ formatRole(member.role) }}
</span>
</template>
</Combobox>
</div>
</div>
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
<span
v-if="member.pending"
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
>
{{ formatMessage(messages.pendingLabel) }}
</span>
<span
v-else-if="member.joinedAt"
v-tooltip="formatDate(member.joinedAt)"
class="inline-block max-w-full truncate"
>
{{ formatRelativeTime(member.joinedAt) }}
</span>
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
</div>
<div class="flex min-w-0 items-center justify-end pr-4">
<ButtonStyled v-if="!member.isOwner" circular type="transparent">
<TeleportOverflowMenu
:options="memberActionOptions(member)"
btn-class="hover:!filter-none focus-visible:!filter-none active:!scale-100 active:!filter-none"
>
<MoreVerticalIcon aria-hidden="true" class="size-5" />
<span class="sr-only">
{{ formatMessage(messages.memberActionsLabel, { username: member.user.username }) }}
</span>
<template #resend-invite>
<SendIcon aria-hidden="true" />
{{ resendInviteLabel(member) }}
</template>
<template #cancel-invite>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelInvite) }}
</template>
<template #remove-user>
<UserXIcon aria-hidden="true" />
{{ formatMessage(messages.removeUser) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div
class="grid min-h-14 grid-cols-[3.75rem_7.25rem_minmax(0,1fr)_2.75rem] bg-surface-3 sm:h-14 sm:grid-cols-[32%_28%_28%_12%]"
>
<div class="flex items-center pl-4 font-semibold text-secondary">
{{ formatMessage(messages.userColumn) }}
</div>
<div class="flex items-center font-semibold text-secondary">
{{ formatMessage(messages.roleColumn) }}
</div>
<div class="flex items-center font-semibold text-secondary">
{{ formatMessage(messages.joinedColumn) }}
</div>
<div class="flex items-center justify-end pr-4 font-semibold text-secondary">
{{ formatMessage(messages.actionsColumn) }}
</div>
</div>
<div
class="border-0 border-t border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary"
>
{{ formatMessage(messages.emptyState) }}
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
ChevronUpIcon,
MoreVerticalIcon,
SendIcon,
UserXIcon,
XIcon,
} from '@modrinth/assets'
import { type Component, computed, onMounted, onUnmounted, ref } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.vue'
import type { ServerAccessMember, ServerAccessRole, ServerAccessRoleOption } from './types'
const props = withDefaults(
defineProps<{
members: ServerAccessMember[]
roles: ServerAccessRoleOption[]
canManageUsers?: boolean
permissionDeniedMessage?: string
}>(),
{
canManageUsers: true,
},
)
const emit = defineEmits<{
updateRole: [member: ServerAccessMember, role: ServerAccessRole]
resendInvite: [member: ServerAccessMember]
cancelInvite: [member: ServerAccessMember]
removeMember: [member: ServerAccessMember]
}>()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const messages = defineMessages({
userColumn: {
id: 'servers.access-table.column.user',
defaultMessage: 'User',
},
roleColumn: {
id: 'servers.access-table.column.role',
defaultMessage: 'Role',
},
joinedColumn: {
id: 'servers.access-table.column.joined',
defaultMessage: 'Joined',
},
actionsColumn: {
id: 'servers.access-table.column.actions',
defaultMessage: 'Actions',
},
memberActionsLabel: {
id: 'servers.access-table.member-actions-label',
defaultMessage: 'Actions for {username}',
},
pendingLabel: {
id: 'servers.access-table.pending',
defaultMessage: 'Pending',
},
unknownJoinedDate: {
id: 'servers.access-table.unknown-joined-date',
defaultMessage: '—',
},
resendInvite: {
id: 'servers.access-table.action.resend-invite',
defaultMessage: 'Resend invite',
},
cancelInvite: {
id: 'servers.access-table.action.cancel-invite',
defaultMessage: 'Cancel invite',
},
removeUser: {
id: 'servers.access-table.action.remove-user',
defaultMessage: 'Remove user',
},
emptyState: {
id: 'servers.access-table.empty',
defaultMessage: 'No users match your filters.',
},
userAvatarAlt: {
id: 'servers.access-table.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
ownerRole: {
id: 'servers.access-role.owner',
defaultMessage: 'Owner',
},
editorRole: {
id: 'servers.access-role.editor',
defaultMessage: 'Editor',
},
viewerRole: {
id: 'servers.access-role.viewer',
defaultMessage: 'Limited',
},
resendInviteCooldown: {
id: 'servers.access-table.action.resend-invite-cooldown',
defaultMessage: 'Resend in {seconds}s',
},
})
type AccessTableColumn = 'user' | 'role' | 'joined' | 'actions'
type AccessTableSortableColumn = Exclude<AccessTableColumn, 'actions'>
type AccessTableRow = ServerAccessMember & Record<string, unknown>
type OverflowMenuOption = {
id: string
icon?: Component
action: () => void
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
disabled?: boolean
tooltip?: string
}
const columns = computed<TableColumn<AccessTableColumn>[]>(() => [
{ key: 'user', label: formatMessage(messages.userColumn), width: '32%', enableSorting: true },
{ key: 'role', label: formatMessage(messages.roleColumn), width: '28%', enableSorting: true },
{ key: 'joined', label: formatMessage(messages.joinedColumn), enableSorting: true },
{ key: 'actions', label: formatMessage(messages.actionsColumn), align: 'right', width: '7rem' },
])
const sortColumn = ref<string | undefined>('role')
const sortDirection = ref<SortDirection>('asc')
const now = ref(Date.now())
let nowInterval: ReturnType<typeof setInterval> | null = null
const canManageUsers = computed(() => props.canManageUsers)
const permissionDeniedMessage = computed(
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
)
const accessManagementTooltip = computed(() =>
canManageUsers.value ? undefined : permissionDeniedMessage.value,
)
const roleSortOrder: Record<ServerAccessRole, number> = {
owner: 0,
editor: 1,
viewer: 2,
}
const sortedMembers = computed(() => {
const direction = sortDirection.value === 'asc' ? 1 : -1
const column = normalizeSortColumn(sortColumn.value)
return [...props.members].sort((a, b) => {
const compared = compareMembers(a, b, column)
if (compared !== 0) return compared * direction
return a.user.username.localeCompare(b.user.username)
})
})
const tableMembers = computed<AccessTableRow[]>(() => sortedMembers.value as AccessTableRow[])
onMounted(() => {
nowInterval = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (nowInterval) clearInterval(nowInterval)
})
function formatRole(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return formatMessage(messages.ownerRole)
case 'editor':
return formatMessage(messages.editorRole)
case 'viewer':
return formatMessage(messages.viewerRole)
}
}
const roleComboboxOptions = computed<ComboboxOption<ServerAccessRole>[]>(() =>
props.roles
.filter((role) => role.value !== 'owner')
.map((role) => ({
value: role.value,
label: role.label,
subLabel: role.description,
})),
)
function compareMembers(
a: ServerAccessMember,
b: ServerAccessMember,
column: AccessTableSortableColumn,
): number {
switch (column) {
case 'user':
return a.user.username.localeCompare(b.user.username)
case 'role':
return roleSortOrder[a.role] - roleSortOrder[b.role]
case 'joined':
return joinedTimestamp(a) - joinedTimestamp(b)
}
}
function normalizeSortColumn(column: string | undefined): AccessTableSortableColumn {
return column === 'user' || column === 'role' || column === 'joined' ? column : 'joined'
}
function joinedTimestamp(member: ServerAccessMember): number {
if (member.pending) return Number.NEGATIVE_INFINITY
return member.joinedAt ? new Date(member.joinedAt).getTime() : 0
}
function toggleSort(column: AccessTableSortableColumn) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
return
}
sortColumn.value = column
sortDirection.value = 'asc'
}
function sortIcon(column: AccessTableSortableColumn): Component | null {
if (sortColumn.value !== column) return null
return sortDirection.value === 'asc' ? ChevronUpIcon : ChevronDownIcon
}
function roleClasses(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return 'border-orange !bg-highlight-orange !text-orange'
case 'editor':
return 'border-green !bg-highlight-green !text-brand'
case 'viewer':
return 'border-blue !bg-highlight-blue !text-blue'
}
}
function roleTextClass(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return '!text-orange'
case 'editor':
return '!text-green'
case 'viewer':
return '!text-blue'
}
}
function roleTriggerClass(role: ServerAccessRole): string {
return roleClasses(role)
}
function userProfilePath(username: string): string | undefined {
if (!username || username.includes('@')) return undefined
return `/user/${encodeURIComponent(username)}`
}
function resendInviteCooldownSeconds(member: ServerAccessMember): number {
const availableAt = member.inviteResendAvailableAt
? new Date(member.inviteResendAvailableAt).getTime()
: 0
return Math.max(0, Math.ceil((availableAt - now.value) / 1000))
}
function resendInviteCooldownDisabled(member: ServerAccessMember): boolean {
return resendInviteCooldownSeconds(member) > 0
}
function resendInviteDisabled(member: ServerAccessMember): boolean {
return !canManageUsers.value || resendInviteCooldownDisabled(member)
}
function resendInviteLabel(member: ServerAccessMember): string {
const seconds = resendInviteCooldownSeconds(member)
return seconds > 0
? formatMessage(messages.resendInviteCooldown, { seconds })
: formatMessage(messages.resendInvite)
}
function resendInviteTooltip(member: ServerAccessMember): string {
return canManageUsers.value ? resendInviteLabel(member) : permissionDeniedMessage.value
}
function handleResendInvite(member: ServerAccessMember) {
if (resendInviteDisabled(member)) return
emit('resendInvite', member)
}
function memberAccessActionLabel(member: ServerAccessMember): string {
return member.pending ? formatMessage(messages.cancelInvite) : formatMessage(messages.removeUser)
}
function memberAccessActionTooltip(member: ServerAccessMember): string {
return canManageUsers.value ? memberAccessActionLabel(member) : permissionDeniedMessage.value
}
function handleUpdateRole(member: ServerAccessMember, role: ServerAccessRole) {
if (!canManageUsers.value) return
emit('updateRole', member, role)
}
function handleCancelInvite(member: ServerAccessMember) {
if (!canManageUsers.value) return
emit('cancelInvite', member)
}
function handleRemoveMember(member: ServerAccessMember) {
if (!canManageUsers.value) return
emit('removeMember', member)
}
function memberActionOptions(member: ServerAccessMember): OverflowMenuOption[] {
return [
{
id: 'resend-invite',
icon: SendIcon,
action: () => handleResendInvite(member),
shown: member.pending,
disabled: resendInviteDisabled(member),
tooltip: resendInviteTooltip(member),
},
{
id: 'cancel-invite',
icon: XIcon,
action: () => handleCancelInvite(member),
color: 'red',
shown: member.pending,
disabled: !canManageUsers.value,
tooltip: memberAccessActionTooltip(member),
},
{
id: 'remove-user',
icon: UserXIcon,
action: () => handleRemoveMember(member),
color: 'red',
shown: !member.pending,
disabled: !canManageUsers.value,
tooltip: memberAccessActionTooltip(member),
},
]
}
</script>
@@ -0,0 +1,13 @@
<template>
<div class="audit-log-table-event min-w-0 truncate">
<component :is="event.component" v-bind="event.props" class="audit-log-table-event-component" />
</div>
</template>
<script setup lang="ts">
import type { ParsedAuditEvent } from './events/types'
defineProps<{
event: ParsedAuditEvent
}>()
</script>
@@ -0,0 +1,706 @@
<template>
<div class="@container flex flex-col gap-4">
<div class="flex min-w-0 flex-col items-start gap-3 @[640px]:flex-row @[640px]:items-center">
<TimeFramePicker
v-model:mode="timeframeMode"
v-model:preset="timeframePreset"
v-model:last-amount="timeframeLastAmount"
v-model:last-unit="timeframeLastUnit"
v-model:custom-start-date="timeframeCustomStartDate"
v-model:custom-end-date="timeframeCustomEndDate"
:class="timeframePickerClass"
:trigger-class="timeframePickerTriggerClass"
/>
<template v-if="slots.filters">
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-5 @[640px]:ml-1 @[640px]:block"></div>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<slot name="filters"></slot>
</div>
</template>
</div>
<div class="audit-log-content-frame relative overflow-hidden" :style="contentFrameStyle">
<div ref="contentBody" class="min-w-0">
<Table
v-if="filteredEntries.length > 0"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
class="audit-log-table hidden @[800px]:block"
:columns="columns"
:data="tableEntries"
row-key="id"
:row-transition-name="rowTransitionName"
>
<template #header-world="{ column }">
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
<span class="min-w-0 truncate">{{ column.label }}</span>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
popper-class="v-popper--interactive"
placement="top"
:delay="{ show: 200, hide: 100 }"
no-auto-focus
>
<button
type="button"
:aria-label="formatMessage(messages.instanceTooltipTitle)"
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
>
<UnknownIcon class="size-4" aria-hidden="true" />
</button>
<template #popper>
<div class="grid !w-64 gap-1">
<h3 class="m-0 whitespace-nowrap text-base w-full font-bold text-contrast">
{{ formatMessage(messages.instanceTooltipTitle) }}
</h3>
<p
class="m-0 text-wrap text-sm w-full font-medium leading-tight text-secondary"
>
{{ formatMessage(messages.instanceTooltipDescription) }}
</p>
</div>
</template>
</Tooltip>
</span>
</template>
<template #cell-user="{ row: entry }">
<AutoLink
v-tooltip="actorName(entry)"
:to="actorProfilePath(entry)"
class="flex min-w-0 items-center gap-2 whitespace-nowrap"
:class="actorProfilePath(entry) ? 'text-primary hover:underline' : ''"
>
<Avatar
:src="
entry.actor.id === 'support'
? IntercomBubbleIcon
: (entry.actor.avatarUrl ?? undefined)
"
:alt="formatMessage(messages.userAvatarAlt, { username: actorName(entry) })"
:tint-by="entry.actor.username"
size="22px"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-medium"
:class="entry.actor.id === 'support' ? 'text-blue' : ''"
>
{{ actorName(entry) }}
</span>
</AutoLink>
</template>
<template #cell-event="{ row: entry }">
<AuditLogEventCell :event="entry.event" />
</template>
<template #cell-world="{ row: entry }">
<span
v-tooltip="entry.world?.name"
class="block min-w-0 truncate whitespace-nowrap"
:class="entry.world ? 'text-primary' : 'text-secondary'"
>
{{ entry.world?.name ?? '—' }}
</span>
</template>
<template #cell-time="{ row: entry }">
<span
v-tooltip="formatDate(entry.timestamp)"
class="inline-block whitespace-nowrap align-middle leading-6"
>
{{ formatRelativeTime(entry.timestamp) }}
</span>
</template>
</Table>
<TransitionGroup
v-if="filteredEntries.length > 0"
name="audit-log-card"
tag="div"
class="flex flex-col gap-3 @[800px]:hidden"
>
<div
v-for="entry in filteredEntries"
:key="entry.id"
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<AutoLink
v-tooltip="actorName(entry)"
:to="actorProfilePath(entry)"
class="inline-flex min-w-0 items-center gap-2 self-start"
:class="actorProfilePath(entry) ? 'text-primary hover:underline' : 'text-primary'"
>
<Avatar
:src="actorAvatarSrc(entry)"
:alt="formatMessage(messages.userAvatarAlt, { username: actorName(entry) })"
:tint-by="entry.actor.username"
size="24px"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-medium"
:class="entry.actor.id === 'support' ? 'text-blue' : ''"
>
{{ actorName(entry) }}
</span>
</AutoLink>
<div class="min-w-0">
<component :is="entry.event.component" v-bind="entry.event.props" />
</div>
<div class="flex min-w-0 items-center gap-1 text-sm text-secondary">
<span v-if="showWorldColumn" v-tooltip="entry.world?.name" class="min-w-0 truncate">
{{ entry.world?.name ?? formatMessage(messages.serverScope) }}
</span>
<BulletDivider v-if="showWorldColumn" class="shrink-0" />
<span v-tooltip="formatDate(entry.timestamp)" class="shrink-0">
{{ formatRelativeTime(entry.timestamp) }}
</span>
</div>
</div>
</TransitionGroup>
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<div
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
:class="
showWorldColumn
? '@[800px]:grid-cols-[18%_52%_20%_10%]'
: '@[800px]:grid-cols-[26%_58%_16%]'
"
>
<div class="hidden items-center pl-4 pr-2 font-semibold text-secondary @[800px]:flex">
{{ formatMessage(messages.userColumn) }}
</div>
<div class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex">
{{ formatMessage(messages.eventColumn) }}
</div>
<div
v-if="showWorldColumn"
class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex"
>
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
<span class="min-w-0 truncate">{{ formatMessage(messages.worldColumn) }}</span>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
popper-class="v-popper--interactive"
placement="top"
:delay="{ show: 200, hide: 100 }"
no-auto-focus
>
<button
type="button"
:aria-label="formatMessage(messages.instanceTooltipTitle)"
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
>
<UnknownIcon class="size-4" aria-hidden="true" />
</button>
<template #popper>
<div class="grid !w-64 gap-1">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.instanceTooltipTitle) }}
</h3>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.instanceTooltipDescription) }}
</p>
</div>
</template>
</Tooltip>
</span>
</div>
<div
class="hidden items-center justify-end pl-2 pr-4 font-semibold text-secondary @[800px]:flex"
>
{{ formatMessage(messages.timeColumn) }}
</div>
</div>
<div
class="border-0 border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
>
{{ formatMessage(emptyStateMessage) }}
</div>
</div>
</div>
<Transition name="audit-log-loading-fade">
<div
v-if="loading"
class="pointer-events-none absolute bottom-px left-px right-px top-0 z-20 animate-audit-log-bpulse rounded-[15px] bg-surface-3 @[800px]:top-[57px] @[800px]:rounded-t-none"
aria-hidden="true"
/>
</Transition>
</div>
<div
v-if="loadingMore"
class="h-8 animate-audit-log-bpulse rounded-xl bg-surface-3"
aria-hidden="true"
></div>
<div v-if="hasMore" ref="loadMoreSentinel" class="h-px"></div>
</div>
</template>
<script setup lang="ts">
import { IntercomBubbleIcon, UnknownIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import BulletDivider from '../../base/BulletDivider.vue'
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
import TimeFramePicker, {
type TimeFrameLastUnit,
type TimeFrameMode,
type TimeFramePreset,
} from '../../base/TimeFramePicker.vue'
import AuditLogEventCell from './AuditLogEventCell.vue'
import type { ServerAuditLogEntry, ServerAuditLogFilters } from './types'
const props = defineProps<{
entries: ServerAuditLogEntry[]
hasActiveExternalFilters?: boolean
hasMore?: boolean
loading?: boolean
loadingMore?: boolean
showWorldColumn?: boolean
suppressRowTransitions?: boolean
}>()
const emit = defineEmits<{
'load-more': []
}>()
const query = defineModel<string>('query', { default: '' })
const timeframeMode = defineModel<TimeFrameMode>('timeframeMode', { default: 'preset' })
const timeframePreset = defineModel<TimeFramePreset>('timeframePreset', { default: 'all_time' })
const timeframeLastAmount = defineModel<number>('timeframeLastAmount', { default: 30 })
const timeframeLastUnit = defineModel<TimeFrameLastUnit>('timeframeLastUnit', { default: 'days' })
const timeframeCustomStartDate = defineModel<string>('timeframeCustomStartDate', { default: '' })
const timeframeCustomEndDate = defineModel<string>('timeframeCustomEndDate', { default: '' })
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'desc' })
const filters = defineModel<ServerAuditLogFilters>('filters', {
default: () => ({
userId: null,
worldId: null,
}),
})
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const slots = useSlots()
const sortColumn = ref<string | undefined>('time')
const suppressSortRowTransitions = ref(false)
const loadMoreSentinel = ref<HTMLElement | null>(null)
const contentBody = ref<HTMLElement | null>(null)
const contentHeight = ref<number | null>(null)
const showWorldColumn = computed(() => props.showWorldColumn === true)
let loadMoreObserver: IntersectionObserver | null = null
let contentResizeObserver: ResizeObserver | null = null
let sortTransitionResetTimeout: ReturnType<typeof setTimeout> | null = null
const messages = defineMessages({
supportActor: {
id: 'servers.audit-log.actor.support',
defaultMessage: 'Support',
},
userColumn: {
id: 'servers.audit-log.column.user',
defaultMessage: 'User',
},
worldColumn: {
id: 'servers.audit-log.column.world',
defaultMessage: 'Instance',
},
instanceTooltipTitle: {
id: 'servers.audit-log.column.world.tooltip-title',
defaultMessage: 'Coming soon!',
},
instanceTooltipDescription: {
id: 'servers.audit-log.column.world.tooltip-description',
defaultMessage:
'Server instances are contained environments with their own installed content and world files.',
},
eventColumn: {
id: 'servers.audit-log.column.event',
defaultMessage: 'Actions',
},
timeColumn: {
id: 'servers.audit-log.column.time',
defaultMessage: 'Time',
},
emptyState: {
id: 'servers.audit-log.empty',
defaultMessage: 'No activity matches your filters.',
},
noActivityEmptyState: {
id: 'servers.audit-log.empty.no-activity',
defaultMessage: 'Perform an action on your server and you will see it here!',
},
userAvatarAlt: {
id: 'servers.audit-log.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
serverScope: {
id: 'servers.audit-log.scope.server',
defaultMessage: 'Server',
},
})
const timeframePickerClass = computed(() =>
slots.filters ? '!w-full @[640px]:!w-[225px] shrink-0' : '!w-full @[640px]:!w-[225px]',
)
const timeframePickerTriggerClass =
'!h-10 !min-h-10 !w-full !rounded-[14px] !bg-surface-4 !py-2.5 !pl-4 !pr-3 !text-base shadow-[0px_1px_1px_rgba(0,0,0,0.3),0px_1px_1.5px_rgba(0,0,0,0.15)]'
onMounted(() => {
updateLoadMoreObserver()
updateContentHeightObserver()
})
onBeforeUnmount(() => {
loadMoreObserver?.disconnect()
contentResizeObserver?.disconnect()
if (sortTransitionResetTimeout) {
clearTimeout(sortTransitionResetTimeout)
}
})
watch(
() => [props.hasMore, props.loadingMore, loadMoreSentinel.value] as const,
() => updateLoadMoreObserver(),
{ flush: 'post' },
)
watch(
sortDirection,
(_direction, previousDirection) => {
if (previousDirection === undefined) return
suppressSortRowTransitions.value = true
scheduleSortTransitionReset(1500)
},
{ flush: 'sync' },
)
watch(
() => props.entries,
() => {
if (suppressSortRowTransitions.value) {
scheduleSortTransitionReset(120)
}
},
{ flush: 'post' },
)
type AuditLogTableColumn = 'user' | 'event' | 'world' | 'time'
type AuditLogTableRow = ServerAuditLogEntry & Record<string, unknown>
const columns = computed<TableColumn<AuditLogTableColumn>[]>(() => {
const tableColumns: TableColumn<AuditLogTableColumn>[] = [
{
key: 'user',
label: formatMessage(messages.userColumn),
width: showWorldColumn.value ? '18%' : '26%',
},
{
key: 'event',
label: formatMessage(messages.eventColumn),
width: showWorldColumn.value ? '52%' : '58%',
},
]
if (showWorldColumn.value) {
tableColumns.push({
key: 'world',
label: formatMessage(messages.worldColumn),
width: '20%',
})
}
tableColumns.push({
key: 'time',
label: formatMessage(messages.timeColumn),
align: 'right',
enableSorting: true,
width: showWorldColumn.value ? '10%' : '16%',
})
return tableColumns
})
const rowTransitionName = computed(() =>
props.suppressRowTransitions || suppressSortRowTransitions.value ? undefined : 'audit-log-row',
)
const filteredEntries = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
return props.entries
.filter((entry) => {
if (filters.value.userId && entry.actor.id !== filters.value.userId) return false
if (
showWorldColumn.value &&
filters.value.worldId &&
entry.world?.id !== filters.value.worldId
) {
return false
}
if (!normalizedQuery) return true
return [
entry.actor.username,
showWorldColumn.value ? entry.world?.name : undefined,
entry.event.searchText,
entry.event.key,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.some((value) => value.toLowerCase().includes(normalizedQuery))
})
.slice()
.sort((a, b) => {
const leftTimestamp = new Date(a.timestamp).getTime()
const rightTimestamp = new Date(b.timestamp).getTime()
return sortDirection.value === 'asc'
? leftTimestamp - rightTimestamp
: rightTimestamp - leftTimestamp
})
})
const tableEntries = computed<AuditLogTableRow[]>(() => filteredEntries.value as AuditLogTableRow[])
const contentFrameStyle = computed(() =>
contentHeight.value === null ? undefined : { height: `${contentHeight.value}px` },
)
watch(
() => [filteredEntries.value.length, props.loading] as const,
() => updateContentHeight(),
{ flush: 'post' },
)
const hasActiveTimeframeFilter = computed(() => {
if (timeframeMode.value === 'preset') {
return timeframePreset.value !== 'all_time'
}
if (timeframeMode.value === 'last') {
return true
}
return Boolean(timeframeCustomStartDate.value || timeframeCustomEndDate.value)
})
const hasActiveFilters = computed(
() =>
props.hasActiveExternalFilters ||
query.value.trim().length > 0 ||
hasActiveTimeframeFilter.value ||
!!filters.value.userId ||
(showWorldColumn.value && !!filters.value.worldId),
)
const emptyStateMessage = computed(() =>
props.entries.length === 0 && !hasActiveFilters.value
? messages.noActivityEmptyState
: messages.emptyState,
)
function actorName(entry: ServerAuditLogEntry): string {
if (entry.actor.id !== 'support') return entry.actor.username
return entry.actor.username === 'support'
? formatMessage(messages.supportActor)
: entry.actor.username
}
function actorAvatarSrc(entry: ServerAuditLogEntry): string | undefined {
return entry.actor.id === 'support' ? IntercomBubbleIcon : (entry.actor.avatarUrl ?? undefined)
}
function actorProfilePath(entry: ServerAuditLogEntry): string | undefined {
return entry.actor.profilePath
}
function updateLoadMoreObserver() {
loadMoreObserver?.disconnect()
loadMoreObserver = null
if (!props.hasMore || typeof IntersectionObserver === 'undefined') {
return
}
nextTick(() => {
if (!loadMoreSentinel.value || !props.hasMore) {
return
}
loadMoreObserver?.disconnect()
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting) && !props.loadingMore) {
emit('load-more')
}
},
{ rootMargin: '400px 0px' },
)
loadMoreObserver.observe(loadMoreSentinel.value)
})
}
function updateContentHeightObserver() {
contentResizeObserver?.disconnect()
contentResizeObserver = null
nextTick(() => {
updateContentHeight()
if (!contentBody.value || typeof ResizeObserver === 'undefined') {
return
}
contentResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.contentRect.height ?? contentBody.value?.getBoundingClientRect().height ?? 0
setContentHeight(height)
})
contentResizeObserver.observe(contentBody.value)
})
}
function updateContentHeight() {
nextTick(() => {
if (!contentBody.value) return
setContentHeight(contentBody.value.getBoundingClientRect().height)
})
}
function setContentHeight(height: number) {
contentHeight.value = Math.ceil(height)
}
function scheduleSortTransitionReset(delay: number) {
if (sortTransitionResetTimeout) {
clearTimeout(sortTransitionResetTimeout)
}
sortTransitionResetTimeout = setTimeout(() => {
suppressSortRowTransitions.value = false
sortTransitionResetTimeout = null
}, delay)
}
</script>
<style>
@media (prefers-reduced-motion: no-preference) {
.audit-log-content-frame {
transition: height 220ms ease-in-out;
}
}
@keyframes audit-log-bpulse {
50% {
filter: brightness(75%);
}
}
.animate-audit-log-bpulse {
animation: audit-log-bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.audit-log-table :is(th, td) {
height: 54px;
padding-left: 0.5rem;
padding-right: 0.5rem;
vertical-align: middle;
}
.audit-log-table :is(th, td):first-child {
padding-left: 1rem;
}
.audit-log-table :is(th, td):last-child {
padding-right: 1rem;
}
.audit-log-table tbody tr {
height: 54px;
max-height: 54px;
}
.audit-log-table tbody td {
height: 54px;
max-height: 54px;
padding-bottom: 0;
padding-top: 0;
}
.audit-log-table-event {
line-height: 1.375rem;
}
.audit-log-table-event-component > span {
line-height: inherit;
}
@container (min-width: 1040px) {
.audit-log-table :is(th, td) {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.audit-log-table :is(th, td):first-child {
padding-left: 1rem;
}
.audit-log-table :is(th, td):last-child {
padding-right: 1rem;
}
.audit-log-table-event {
line-height: 1.375rem;
}
}
.audit-log-loading-fade-enter-active,
.audit-log-loading-fade-leave-active {
transition: opacity 250ms ease-in-out;
}
.audit-log-loading-fade-enter-from,
.audit-log-loading-fade-leave-to {
opacity: 0;
}
.audit-log-row-enter-active,
.audit-log-row-leave-active,
.audit-log-row-move,
.audit-log-card-enter-active,
.audit-log-card-leave-active,
.audit-log-card-move {
transition:
opacity 180ms ease-in-out,
transform 180ms ease-in-out;
}
.audit-log-row-enter-from,
.audit-log-card-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.audit-log-row-leave-to,
.audit-log-card-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
@@ -0,0 +1,459 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
width="min(34rem, calc(100vw - 2rem))"
max-width="34rem"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast" for="grant-access-target">
{{ formatMessage(messages.targetLabel) }}
</label>
<Combobox
id="grant-access-target"
:model-value="undefined"
:options="suggestionOptions"
:search-placeholder="formatMessage(messages.targetPlaceholder)"
:placeholder="formatMessage(messages.targetPlaceholder)"
:no-options-message="targetLookupMessage"
:min-search-length-to-open="suggestionMinimumLength"
:disable-search-filter="usesRemoteLookup"
searchable
show-search-icon
:show-chevron="false"
search-autocomplete="off"
search-autocorrect="off"
search-autocapitalize="none"
:search-spellcheck="false"
@open="targetComboboxOpen = true"
@close="targetComboboxOpen = false"
@search-input="handleTargetSearch"
@select="handleTargetSelect"
>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2">
<Avatar
:src="findSuggestion(item.value)?.avatarUrl"
:alt="formatMessage(messages.suggestionAvatarAlt, { username: item.label })"
:tint-by="item.label"
size="1.5rem"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-semibold"
:class="isSelected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
<span class="m-0 text-base text-primary">
{{ formatMessage(messages.targetHelp) }}
</span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.roleLabel) }}
</span>
<div class="flex flex-col gap-3">
<button
v-for="role in grantableRoles"
:key="role.value"
type="button"
class="group flex w-full items-center gap-3 rounded-[20px] border border-solid p-3 text-left transition-all hover:brightness-110 active:scale-[0.98]"
:class="
selectedRole === role.value
? 'border-brand bg-brand-highlight'
: 'border-transparent bg-surface-4'
"
@click="selectedRole = role.value"
>
<span
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border border-solid"
:class="
selectedRole === role.value
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 text-secondary'
"
>
<component :is="role.icon" class="size-8" stroke-width="1.5" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-contrast">{{ role.label }}</span>
<span class="text-sm font-medium text-primary">{{ role.description }}</span>
</span>
</button>
</div>
<p class="m-0 text-base text-primary">
<IntlFormatted :message-id="messages.permissionsHelp">
<template #link="{ children }">
<a
class="font-medium text-blue hover:underline"
href="/news/article/server-access/"
target="_blank"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<Checkbox
v-if="showAddAsFriend"
v-model="addAsFriend"
:label="formatMessage(messages.addAsFriend)"
label-class="text-base text-contrast"
class="mt-2"
/>
</Transition>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="grantPermissionTooltip" :disabled="!canSubmit" @click="submit">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.inviteButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { EyeIcon, PencilIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Checkbox from '../../base/Checkbox.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
import NewModal from '../../modal/NewModal.vue'
import type {
GrantServerAccessPayload,
ServerAccessInviteSuggestion,
ServerAccessMember,
ServerAccessRole,
} from './types'
const props = withDefaults(
defineProps<{
members?: ServerAccessMember[]
suggestions?: ServerAccessInviteSuggestion[]
friendIds?: string[]
searchUsers?: (query: string) => Promise<ServerAccessInviteSuggestion[]>
canGrant?: boolean
permissionDeniedMessage?: string
}>(),
{
members: () => [],
suggestions: () => [],
friendIds: () => [],
canGrant: true,
},
)
const emit = defineEmits<{
grant: [payload: GrantServerAccessPayload]
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const target = ref('')
const selectedRole = ref<Exclude<ServerAccessRole, 'owner'>>('editor')
const addAsFriend = ref(true)
const suggestionMinimumLength = 1
const remoteSuggestions = ref<ServerAccessInviteSuggestion[]>([])
const targetLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
const targetLookupRequestId = ref(0)
const hasSelectedTarget = ref(false)
const targetComboboxOpen = ref(false)
const messages = defineMessages({
header: {
id: 'servers.grant-access-modal.header',
defaultMessage: 'Add user',
},
targetLabel: {
id: 'servers.grant-access-modal.target.label',
defaultMessage: 'Modrinth username',
},
targetPlaceholder: {
id: 'servers.grant-access-modal.target.placeholder',
defaultMessage: 'Enter Modrinth username',
},
noSuggestions: {
id: 'servers.grant-access-modal.target.no-suggestions',
defaultMessage: 'No matching users found.',
},
searching: {
id: 'servers.grant-access-modal.target.searching',
defaultMessage: 'Searching...',
},
targetHelp: {
id: 'servers.grant-access-modal.target.help',
defaultMessage: 'Do not use their Minecraft username.',
},
roleLabel: {
id: 'servers.grant-access-modal.role.label',
defaultMessage: 'Select role',
},
editorRole: {
id: 'servers.grant-access-modal.role.editor',
defaultMessage: 'Editor',
},
editorDescription: {
id: 'servers.grant-access-modal.role.editor-description',
defaultMessage: 'Manage instance content, files, backups, and other settings.',
},
viewerRole: {
id: 'servers.grant-access-modal.role.viewer',
defaultMessage: 'Limited',
},
viewerDescription: {
id: 'servers.grant-access-modal.role.viewer-description',
defaultMessage: 'Start, stop, and view the server without making changes.',
},
permissionsHelp: {
id: 'servers.grant-access-modal.permissions-help',
defaultMessage: 'View the full list of permissions for each role <link>here</link>.',
},
addAsFriend: {
id: 'servers.grant-access-modal.add-as-friend',
defaultMessage: 'Also send a friend request',
},
cancelButton: {
id: 'servers.grant-access-modal.cancel',
defaultMessage: 'Cancel',
},
inviteButton: {
id: 'servers.grant-access-modal.invite',
defaultMessage: 'Invite',
},
suggestionAvatarAlt: {
id: 'servers.grant-access-modal.suggestion-avatar-alt',
defaultMessage: "{username}'s avatar",
},
alreadyMemberTooltip: {
id: 'servers.grant-access-modal.already-member-tooltip',
defaultMessage: 'This user has already been invited',
},
})
const grantableRoles = computed(() => [
{
value: 'editor' as const,
label: formatMessage(messages.editorRole),
description: formatMessage(messages.editorDescription),
icon: PencilIcon,
},
{
value: 'viewer' as const,
label: formatMessage(messages.viewerRole),
description: formatMessage(messages.viewerDescription),
icon: EyeIcon,
},
])
const normalizedTarget = computed(() => target.value.trim())
const usesRemoteLookup = computed(() => !!props.searchUsers)
const matchedSuggestion = computed(() => findSuggestion(normalizedTarget.value))
const selectedTargetUserId = computed(() => matchedSuggestion.value?.id)
const friendIdSet = computed(() => new Set(props.friendIds.map((id) => id.toLowerCase())))
const targetIsFriend = computed(() => {
const userId = selectedTargetUserId.value
return !!userId && friendIdSet.value.has(userId.toLowerCase())
})
const hasResolvedTarget = computed(() => {
const suggestion = matchedSuggestion.value
if (!suggestion) return false
const normalizedSuggestionId = suggestion.id.toLowerCase()
const normalizedSuggestionUsername = suggestion.username.toLowerCase()
const normalizedValue = normalizedTarget.value.toLowerCase()
return (
hasSelectedTarget.value ||
normalizedSuggestionId === normalizedValue ||
normalizedSuggestionUsername === normalizedValue
)
})
const showAddAsFriend = computed(() => canAddAsFriend.value && !targetComboboxOpen.value)
const canAddAsFriend = computed(
() => hasResolvedTarget.value && !!matchedSuggestion.value && !targetIsFriend.value,
)
const existingMember = computed(() => findExistingMember())
const canInvite = computed(
() =>
normalizedTarget.value.length > 0 &&
!!selectedRole.value &&
!!matchedSuggestion.value &&
!existingMember.value &&
(!usesRemoteLookup.value || (targetLookupStatus.value === 'loaded' && hasResolvedTarget.value)),
)
const canSubmit = computed(() => props.canGrant && canInvite.value)
const permissionDeniedMessage = computed(
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
)
const grantPermissionTooltip = computed(() => {
if (!props.canGrant) return permissionDeniedMessage.value
if (existingMember.value) return formatMessage(messages.alreadyMemberTooltip)
return undefined
})
const targetLookupMessage = computed(() =>
usesRemoteLookup.value && targetLookupStatus.value !== 'loaded'
? formatMessage(messages.searching)
: formatMessage(messages.noSuggestions),
)
const inviteSuggestions = computed(() => {
const suggestions = new Map<string, ServerAccessInviteSuggestion>()
for (const suggestion of [...remoteSuggestions.value, ...props.suggestions]) {
suggestions.set(suggestion.id.toLowerCase(), suggestion)
suggestions.set(suggestion.username.toLowerCase(), suggestion)
}
return [...new Set(suggestions.values())]
})
const suggestionOptions = computed<ComboboxOption<string>[]>(() =>
inviteSuggestions.value.map((suggestion) => ({
value: suggestion.username,
label: suggestion.username,
searchTerms: [suggestion.username, suggestion.id, suggestion.email].filter(Boolean) as string[],
})),
)
function findSuggestion(value: string) {
const normalizedValue = value.trim().toLowerCase()
return inviteSuggestions.value.find(
(suggestion) =>
suggestion.username.toLowerCase() === normalizedValue ||
suggestion.id.toLowerCase() === normalizedValue ||
suggestion.email?.toLowerCase() === normalizedValue,
)
}
function findExistingMember() {
const normalizedValue = normalizedTarget.value.toLowerCase()
if (!normalizedValue) return undefined
const suggestion = matchedSuggestion.value
const normalizedSuggestionId = suggestion?.id.toLowerCase()
const normalizedSuggestionUsername = suggestion?.username.toLowerCase()
return props.members.find((member) => {
const normalizedMemberId = member.user.id.toLowerCase()
const normalizedMemberUsername = member.user.username.toLowerCase()
return (
normalizedMemberId === normalizedValue ||
normalizedMemberUsername === normalizedValue ||
(!!normalizedSuggestionId && normalizedMemberId === normalizedSuggestionId) ||
(!!normalizedSuggestionUsername && normalizedMemberUsername === normalizedSuggestionUsername)
)
})
}
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
const searchUsers = props.searchUsers
if (!searchUsers) return
try {
const users = await searchUsers(query)
if (requestId !== targetLookupRequestId.value || query !== normalizedTarget.value) return
remoteSuggestions.value = users
} catch {
if (requestId !== targetLookupRequestId.value || query !== normalizedTarget.value) return
remoteSuggestions.value = []
} finally {
if (requestId === targetLookupRequestId.value && query === normalizedTarget.value) {
targetLookupStatus.value = 'loaded'
}
}
}, 250)
function handleTargetSearch(value: string) {
target.value = value
remoteSuggestions.value = []
hasSelectedTarget.value = false
targetLookupRequestId.value += 1
if (!usesRemoteLookup.value) return
if (normalizedTarget.value.length < suggestionMinimumLength) {
targetLookupStatus.value = 'idle'
return
}
targetLookupStatus.value = 'loading'
void searchTargetUsers(normalizedTarget.value, targetLookupRequestId.value)
}
function handleTargetSelect(option: ComboboxOption<string>) {
target.value = option.value
hasSelectedTarget.value = true
}
function reset() {
target.value = ''
selectedRole.value = 'editor'
addAsFriend.value = true
remoteSuggestions.value = []
targetLookupStatus.value = 'idle'
targetLookupRequestId.value += 1
hasSelectedTarget.value = false
targetComboboxOpen.value = false
}
function show(event?: MouseEvent) {
reset()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
function submit() {
if (!canSubmit.value) return
const user = matchedSuggestion.value
if (!user) return
const payload: GrantServerAccessPayload = {
target: normalizedTarget.value,
user,
role: selectedRole.value,
addAsFriend: canAddAsFriend.value && addAsFriend.value,
}
hide()
emit('grant', payload)
}
defineExpose({ show, hide })
</script>
@@ -0,0 +1,321 @@
<template>
<NewModal
ref="modal"
:header="
formatMessage(modalState.shouldCancel ? messages.cancelHeader : messages.header, {
username: modalState.username,
})
"
max-width="470px"
>
<div class="flex flex-col gap-4">
<Admonition type="warning">
{{
formatMessage(
modalState.shouldCancel ? messages.cancelWarningBody : messages.warningBody,
{
username: modalState.username,
},
)
}}
</Admonition>
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
<Avatar
:src="modalState.avatarUrl"
:alt="formatMessage(messages.userAvatarAlt, { username: modalState.username })"
:tint-by="modalState.username"
size="40px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 truncate font-medium text-contrast">{{
modalState.username
}}</span>
<span
v-if="memberStatusLabel"
class="inline-flex h-6 shrink-0 items-center rounded-full border border-solid px-2 py-1 text-sm font-medium leading-none"
:class="memberStatusClasses"
>
{{ memberStatusLabel }}
</span>
</div>
<span class="truncate text-sm text-secondary">{{ memberSubtitle }}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whatHappensLabel)
}}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li
v-for="effect in effectMessages"
:key="effect.id"
class="leading-6 marker:text-secondary"
>
{{ formatMessage(effect) }}
</li>
</ul>
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button v-tooltip="removePermissionTooltip" :disabled="!canRemove" @click="confirm">
<TrashIcon v-if="modalState.shouldCancel" aria-hidden="true" />
<UserXIcon v-else aria-hidden="true" />
{{
formatMessage(modalState.shouldCancel ? messages.cancelButton : messages.removeButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, UserXIcon, XIcon } from '@modrinth/assets'
import { computed, ref, watch } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Admonition from '../../base/Admonition.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import type { ServerAccessRole } from './types'
const props = withDefaults(
defineProps<{
username: string
avatarUrl?: string
role?: ServerAccessRole
joinedAt?: string | null
pending?: boolean
shouldCancel?: boolean
canRemove?: boolean
permissionDeniedMessage?: string
}>(),
{
avatarUrl: undefined,
role: undefined,
joinedAt: null,
pending: false,
shouldCancel: false,
canRemove: true,
},
)
const emit = defineEmits<{
remove: []
}>()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const modal = ref<InstanceType<typeof NewModal>>()
const cachedState = ref({
username: '',
avatarUrl: undefined as string | undefined,
role: undefined as ServerAccessRole | undefined,
joinedAt: null as string | null,
pending: false,
shouldCancel: false,
})
const messages = defineMessages({
header: {
id: 'servers.remove-access-modal.header',
defaultMessage: 'Remove user',
},
cancelHeader: {
id: 'servers.remove-access-modal.cancel-header',
defaultMessage: 'Cancel invite',
},
warningBody: {
id: 'servers.remove-access-modal.warning-body',
defaultMessage:
"If you remove a user from your server, you'll need to re-invite them to restore access.",
},
cancelWarningBody: {
id: 'servers.remove-access-modal.cancel-warning-body',
defaultMessage:
'If you cancel this invite, {username} will need a new invitation before they can join this server.',
},
removeButton: {
id: 'servers.remove-access-modal.remove-button',
defaultMessage: 'Remove user',
},
cancelButton: {
id: 'servers.remove-access-modal.cancel-button',
defaultMessage: 'Cancel invite',
},
userAvatarAlt: {
id: 'servers.remove-access-modal.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
whatHappensLabel: {
id: 'servers.remove-access-modal.what-happens-label',
defaultMessage: 'What happens?',
},
removeEffectAccess: {
id: 'servers.remove-access-modal.remove-effect-access',
defaultMessage:
'They will immediately lose access to the server panel and will no longer be able to edit content',
},
removeEffectJoin: {
id: 'servers.remove-access-modal.remove-effect-join',
defaultMessage:
'They will still be able to join and play on the server unless you make separate changes',
},
cancelEffectAccess: {
id: 'servers.remove-access-modal.cancel-effect-access',
defaultMessage: 'They will not be added to this server',
},
cancelEffectInvite: {
id: 'servers.remove-access-modal.cancel-effect-invite',
defaultMessage: 'You can send them another invite later',
},
addedLabel: {
id: 'servers.remove-access-modal.added-label',
defaultMessage: 'Added {time}',
},
invitedLabel: {
id: 'servers.remove-access-modal.invited-label',
defaultMessage: 'Invited {time}',
},
pendingInviteLabel: {
id: 'servers.remove-access-modal.pending-invite-label',
defaultMessage: 'Pending invite',
},
unknownAddedLabel: {
id: 'servers.remove-access-modal.unknown-added-label',
defaultMessage: 'Added date unknown',
},
ownerRole: {
id: 'servers.access-role.owner',
defaultMessage: 'Owner',
},
editorRole: {
id: 'servers.access-role.editor',
defaultMessage: 'Editor',
},
viewerRole: {
id: 'servers.access-role.viewer',
defaultMessage: 'Limited',
},
})
const modalState = computed(() => (props.username ? currentState() : cachedState.value))
watch(
() =>
[
props.username,
props.avatarUrl,
props.role,
props.joinedAt,
props.pending,
props.shouldCancel,
] as const,
() => {
if (props.username) cachedState.value = currentState()
},
{ immediate: true },
)
const memberStatusLabel = computed(() => {
if (!modalState.value.role) return null
return formatRole(modalState.value.role)
})
const memberStatusClasses = computed(() => {
if (!modalState.value.role) return ''
return roleClasses(modalState.value.role)
})
const memberSubtitle = computed(() => {
if (modalState.value.shouldCancel || modalState.value.pending) {
return modalState.value.joinedAt
? formatMessage(messages.invitedLabel, {
time: formatRelativeTime(modalState.value.joinedAt),
})
: formatMessage(messages.pendingInviteLabel)
}
return modalState.value.joinedAt
? formatMessage(messages.addedLabel, { time: formatRelativeTime(modalState.value.joinedAt) })
: formatMessage(messages.unknownAddedLabel)
})
const effectMessages = computed(() =>
modalState.value.shouldCancel
? [messages.cancelEffectAccess, messages.cancelEffectInvite]
: [messages.removeEffectAccess, messages.removeEffectJoin],
)
const canRemove = computed(() => props.canRemove)
const permissionDeniedMessage = computed(
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
)
const removePermissionTooltip = computed(() =>
canRemove.value ? undefined : permissionDeniedMessage.value,
)
function currentState() {
return {
username: props.username,
avatarUrl: props.avatarUrl,
role: props.role,
joinedAt: props.joinedAt,
pending: props.pending,
shouldCancel: props.shouldCancel,
}
}
function formatRole(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return formatMessage(messages.ownerRole)
case 'editor':
return formatMessage(messages.editorRole)
case 'viewer':
return formatMessage(messages.viewerRole)
}
}
function roleClasses(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return 'border-orange bg-highlight-orange text-orange'
case 'editor':
return 'border-green bg-highlight-green text-brand'
case 'viewer':
return 'border-blue bg-highlight-blue text-blue'
}
}
function show() {
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function confirm() {
if (!canRemove.value) return
hide()
emit('remove')
}
defineExpose({ show, hide })
</script>
@@ -0,0 +1,100 @@
<template>
<BaseEvent>
<span
v-if="isDeleted"
class="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1 whitespace-normal align-middle @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
>
<span class="shrink-0">{{ formatMessage(messages.deletedLabel) }}</span>
<EventEntityList
class="min-w-0"
:entities="addonEntities"
:limit="1"
single-line
entity-text-weight="semibold"
/>
</span>
<IntlFormatted v-else :message-id="message">
<template #content>
<EventEntityList
:entities="addonEntities"
:single-line="true"
:limit="contentLimit"
entity-text-weight="semibold"
/>
</template>
<template #files>
<EventEntityList :entities="fileNames ?? []" :single-line="true" :limit="1" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityList from './EventEntityList.vue'
import type { AuditAddonEventItem, EventEntity } from './types'
const props = defineProps<{
kind: string
addons?: AuditAddonEventItem[]
fileNames?: EventEntity[]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
added: {
id: 'servers.audit-log.event.addon-added',
defaultMessage: 'Added content <content></content>',
},
uploaded: {
id: 'servers.audit-log.event.addon-uploaded',
defaultMessage: 'Uploaded <files></files>',
},
disabled: {
id: 'servers.audit-log.event.addon-disabled',
defaultMessage: 'Disabled content <content></content>',
},
enabled: {
id: 'servers.audit-log.event.addon-enabled',
defaultMessage: 'Enabled content <content></content>',
},
deleted: {
id: 'servers.audit-log.event.addon-deleted',
defaultMessage: 'Deleted content <content></content>',
},
deletedLabel: {
id: 'servers.audit-log.event.addon-deleted-label',
defaultMessage: 'Deleted content',
},
updated: {
id: 'servers.audit-log.event.addon-updated',
defaultMessage: 'Updated content <content></content>',
},
changed: {
id: 'servers.audit-log.event.addon-changed',
defaultMessage: 'Changed content <content></content>',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
added: messages.added,
uploaded: messages.uploaded,
disabled: messages.disabled,
enabled: messages.enabled,
deleted: messages.deleted,
updated: messages.updated,
}
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
const addonEntities = computed(() => props.addons?.map((addon) => addon.project) ?? [])
const isDeleted = computed(() => props.kind === 'deleted')
const shouldShowSingleItem = computed(() =>
['added', 'disabled', 'enabled', 'updated'].includes(props.kind),
)
const contentLimit = computed(() => (shouldShowSingleItem.value ? 1 : undefined))
</script>
@@ -0,0 +1,105 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #backup>
<EventEntityLink v-if="backup" :entity="backup" />
</template>
<template #renamed-backup>
<EventEntityLink v-if="renamedBackup" :entity="renamedBackup" />
</template>
<template #from>
<EventInlineText :text="from ?? ''" class="align-middle font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import EventInlineText from './EventInlineText.vue'
import type { AuditBackupEventItem } from './types'
const props = defineProps<{
kind: string
backup?: AuditBackupEventItem
backupId?: string
from?: string
to?: string
}>()
const messages = defineMessages({
created: {
id: 'servers.audit-log.event.backup-created',
defaultMessage: 'Created backup <backup></backup>',
},
restored: {
id: 'servers.audit-log.event.backup-restored',
defaultMessage: 'Restored backup <backup></backup>',
},
renamed: {
id: 'servers.audit-log.event.backup-renamed',
defaultMessage: 'Renamed backup <from></from> to <renamed-backup></renamed-backup>',
},
deleted: {
id: 'servers.audit-log.event.backup-deleted',
defaultMessage: 'Deleted backup <backup></backup>',
},
changed: {
id: 'servers.audit-log.event.backup-changed',
defaultMessage: 'Changed backup <backup></backup>',
},
createdFallback: {
id: 'servers.audit-log.event.backup-created-fallback',
defaultMessage: 'Created backup',
},
restoredFallback: {
id: 'servers.audit-log.event.backup-restored-fallback',
defaultMessage: 'Restored backup',
},
renamedFallback: {
id: 'servers.audit-log.event.backup-renamed-fallback',
defaultMessage: 'Renamed backup',
},
deletedFallback: {
id: 'servers.audit-log.event.backup-deleted-fallback',
defaultMessage: 'Deleted backup',
},
changedFallback: {
id: 'servers.audit-log.event.backup-changed-fallback',
defaultMessage: 'Changed backup',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
created: messages.created,
restored: messages.restored,
renamed: messages.renamed,
deleted: messages.deleted,
}
const fallbackMessages: Record<string, MessageDescriptor> = {
created: messages.createdFallback,
restored: messages.restoredFallback,
renamed: messages.renamedFallback,
deleted: messages.deletedFallback,
}
const message = computed(() =>
props.backup
? (kindMessages[props.kind] ?? messages.changed)
: (fallbackMessages[props.kind] ?? messages.changedFallback),
)
const renamedBackup = computed(() =>
props.backup
? {
...props.backup,
label: props.to ?? props.backup.label,
}
: undefined,
)
</script>
@@ -0,0 +1,9 @@
<template>
<div class="min-w-0 text-primary">
<span
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-x-1 whitespace-normal align-middle leading-7 @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
>
<slot />
</span>
</div>
</template>
@@ -0,0 +1,80 @@
<template>
<BaseEvent>
{{ formatMessage(message) }}
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import BaseEvent from './BaseEvent.vue'
const props = defineProps<{
action: string
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
serverCreated: {
id: 'servers.audit-log.event.server-created',
defaultMessage: 'Created server',
},
serverReallocated: {
id: 'servers.audit-log.event.server-reallocated',
defaultMessage: 'Reallocated server',
},
serverRepaired: {
id: 'servers.audit-log.event.server-repaired',
defaultMessage: 'Repaired server',
},
serverReset: {
id: 'servers.audit-log.event.server-reset',
defaultMessage: 'Reset server',
},
serverStarted: {
id: 'servers.audit-log.event.server-started',
defaultMessage: 'Started server',
},
serverStopped: {
id: 'servers.audit-log.event.server-stopped',
defaultMessage: 'Stopped server',
},
serverRestarted: {
id: 'servers.audit-log.event.server-restarted',
defaultMessage: 'Restarted server',
},
serverKilled: {
id: 'servers.audit-log.event.server-killed',
defaultMessage: 'Killed server',
},
sftpLogin: {
id: 'servers.audit-log.event.sftp-login',
defaultMessage: 'Logged in via SFTP',
},
consoleCleared: {
id: 'servers.audit-log.event.console-cleared',
defaultMessage: 'Cleared console',
},
unknown: {
id: 'servers.audit-log.event.unknown-basic',
defaultMessage: 'Recorded server activity',
},
})
const actionMessages: Record<string, MessageDescriptor> = {
server_created: messages.serverCreated,
server_reallocated: messages.serverReallocated,
server_repaired: messages.serverRepaired,
server_reset: messages.serverReset,
server_started: messages.serverStarted,
server_stopped: messages.serverStopped,
server_restarted: messages.serverRestarted,
server_killed: messages.serverKilled,
sftp_login: messages.sftpLogin,
console_cleared: messages.consoleCleared,
}
const message = computed(() => actionMessages[props.action] ?? messages.unknown)
</script>
@@ -0,0 +1,142 @@
<template>
<BaseEvent>
<span
v-if="props.kind === 'properties'"
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1 whitespace-normal align-middle @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
>
<span class="shrink-0">{{ formatMessage(messages.propertiesModifiedLabel) }}</span>
<EventInlineText
:text="propertiesLabel"
class="align-middle font-mono text-[0.925em] font-medium text-contrast"
/>
</span>
<IntlFormatted v-else :message-id="message">
<template #version>
<EventInlineText :text="newVersion ?? ''" class="align-middle font-medium text-contrast" />
</template>
<template #loader>
<EventInlineText :text="newLoaderLabel" class="align-middle font-medium text-contrast" />
</template>
<template #command>
<EventInlineText
:text="command ?? ''"
class="align-middle font-mono font-medium text-contrast"
/>
</template>
<template #vendor>
<EventInlineText :text="vendor ?? ''" class="align-middle font-medium text-contrast" />
</template>
<template #java-version>
<EventInlineText :text="version ?? ''" class="align-middle font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventInlineText from './EventInlineText.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind:
| 'loader_version'
| 'game_version'
| 'properties'
| 'startup_command'
| 'java_runtime'
| 'java_version'
newVersion?: string | null
newLoader?: string | null
properties?: EventEntity[]
command?: string
vendor?: string
version?: number
}>()
const messages = defineMessages({
loaderVersionChanged: {
id: 'servers.audit-log.event.loader-version-changed',
defaultMessage: 'Changed loader version to <version></version>',
},
loaderChanged: {
id: 'servers.audit-log.event.loader-changed',
defaultMessage: 'Changed loader to <loader></loader>',
},
loaderAndVersionChanged: {
id: 'servers.audit-log.event.loader-and-version-changed',
defaultMessage: 'Changed loader to <loader></loader> <version></version>',
},
loaderVersionCleared: {
id: 'servers.audit-log.event.loader-version-cleared',
defaultMessage: 'Cleared loader version',
},
gameVersionChanged: {
id: 'servers.audit-log.event.game-version-changed',
defaultMessage: 'Changed Minecraft version to <version></version>',
},
propertiesModified: {
id: 'servers.audit-log.event.server-properties-modified',
defaultMessage: 'Modified server properties <properties></properties>',
},
propertiesModifiedLabel: {
id: 'servers.audit-log.event.server-properties-modified-label',
defaultMessage: 'Modified server properties',
},
startupCommandModified: {
id: 'servers.audit-log.event.startup-command-modified',
defaultMessage: 'Changed startup command to <command></command>',
},
javaRuntimeModified: {
id: 'servers.audit-log.event.java-runtime-modified',
defaultMessage: 'Changed Java runtime to <vendor></vendor>',
},
javaVersionModified: {
id: 'servers.audit-log.event.java-version-modified',
defaultMessage: 'Changed Java version to <java-version></java-version>',
},
configChanged: {
id: 'servers.audit-log.event.config-changed',
defaultMessage: 'Changed server configuration',
},
})
const { formatMessage } = useVIntl()
const propertiesLabel = computed(
() => props.properties?.map((property) => property.label).join(', ') ?? '',
)
const newLoader = computed(() =>
props.kind === 'loader_version' && props.newLoader == null ? 'vanilla' : props.newLoader,
)
const newLoaderLabel = computed(() => formatLoader(newLoader.value))
const kindMessages: Record<string, MessageDescriptor> = {
game_version: messages.gameVersionChanged,
properties: messages.propertiesModified,
startup_command: messages.startupCommandModified,
java_runtime: messages.javaRuntimeModified,
java_version: messages.javaVersionModified,
}
const message = computed(() => {
if (props.kind === 'loader_version') {
if (newLoader.value && props.newVersion) return messages.loaderAndVersionChanged
if (newLoader.value) return messages.loaderChanged
return props.newVersion == null ? messages.loaderVersionCleared : messages.loaderVersionChanged
}
return kindMessages[props.kind] ?? messages.configChanged
})
function formatLoader(loader: string | null | undefined): string {
if (!loader) return ''
return loader
.split(/[-_]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
</script>
@@ -0,0 +1,27 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="messages.commandExecuted">
<template #command>
<EventInlineText :text="command" class="align-middle font-mono font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { defineMessages } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventInlineText from './EventInlineText.vue'
defineProps<{
command: string
}>()
const messages = defineMessages({
commandExecuted: {
id: 'servers.audit-log.event.console-command-executed',
defaultMessage: 'Ran console command <command></command>',
},
})
</script>
@@ -0,0 +1,94 @@
<template>
<AutoLink
:to="entity.to"
class="inline-flex min-w-0 max-w-full flex-wrap items-center gap-0 @[800px]:flex-nowrap"
:class="[
stackSecondary ? '!grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 gap-y-0.5' : '',
entity.to
? `${textWeightClass} text-contrast hover:underline`
: entity.muted
? 'text-secondary'
: `${textWeightClass} text-contrast`,
entity.mono ? 'font-mono text-[0.925em]' : '',
'align-middle',
]"
>
<span
v-if="showAvatar || entity.icon"
class="inline-flex shrink-0 items-center justify-center"
:class="[
stackSecondary ? 'row-span-2 self-center' : 'mr-1',
entity.icon
? 'size-7 rounded-lg border border-solid border-surface-5 bg-surface-4 text-secondary'
: '',
]"
>
<Avatar
v-if="showAvatar"
:src="entity.iconUrl"
:alt="entity.label"
size="1.75rem"
no-shadow
raised
:tint-by="entity.label || entity.id"
:circle="entity.iconShape === 'circle'"
class="inline-flex shrink-0 border border-solid border-surface-5"
:class="entity.iconShape === 'circle' ? '!rounded-full' : '!rounded-lg'"
/>
<component :is="entity.icon" v-else class="size-4" />
</span>
<span
ref="labelRef"
v-tooltip="truncatedTooltip(labelRef, entity.title ?? entity.label)"
class="min-w-0 whitespace-normal break-words leading-7 @[800px]:truncate @[800px]:whitespace-nowrap"
:class="stackSecondary ? 'leading-6 @[800px]:whitespace-normal @[800px]:break-words' : ''"
>
{{ entity.label }}
</span>
<span
v-if="entity.secondaryLabel"
ref="secondaryLabelRef"
v-tooltip="truncatedTooltip(secondaryLabelRef, entity.secondaryLabel)"
class="min-w-0 whitespace-normal break-words text-secondary @[800px]:truncate @[800px]:whitespace-nowrap"
:class="
stackSecondary
? `col-start-2 leading-5 @[800px]:whitespace-normal @[800px]:break-words ${textWeightClass}`
: `entity-secondary-label ${textWeightClass}`
"
>
<template v-if="stackSecondary">{{ entity.secondaryLabel }}</template>
<template v-else>&nbsp;{{ entity.secondaryLabel }}</template>
</span>
</AutoLink>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import { truncatedTooltip } from '#ui/utils/truncate'
import type { EventEntity } from './types'
const props = withDefaults(
defineProps<{
entity: EventEntity
stackSecondary?: boolean
textWeight?: 'medium' | 'semibold'
}>(),
{
stackSecondary: false,
textWeight: 'medium',
},
)
const textWeightClass = computed(() =>
props.textWeight === 'semibold' ? 'font-semibold' : 'font-medium',
)
const showAvatar = computed(
() => props.entity.iconUrl != null || props.entity.iconShape === 'circle',
)
const labelRef = ref<HTMLElement | null>(null)
const secondaryLabelRef = ref<HTMLElement | null>(null)
</script>
@@ -0,0 +1,147 @@
<template>
<span
class="inline-flex max-w-full min-w-0 items-center gap-x-1 align-middle"
:class="
singleLine
? 'flex-wrap gap-y-0.5 whitespace-normal @[800px]:flex-nowrap @[800px]:overflow-hidden @[800px]:whitespace-nowrap'
: 'flex-wrap gap-y-0.5'
"
>
<template v-for="(entity, index) in visibleEntities" :key="entity.id">
<EventEntityLink
:entity="entity"
:text-weight="entityTextWeight"
:class="singleLine ? 'min-w-0 shrink' : ''"
/>
<span v-if="index < visibleEntities.length - 1" class="shrink-0 text-secondary">,</span>
</template>
<Tooltip
v-if="hiddenCount > 0"
theme="dismissable-prompt"
class="inline-flex shrink-0 items-center"
:triggers="['hover', 'focus']"
:popper-triggers="['hover', 'focus']"
popper-class="v-popper--interactive audit-log-entity-list-popper"
placement="top"
:delay="{ show: 200, hide: 100 }"
no-auto-focus
>
<button
type="button"
class="inline-flex min-w-0 cursor-help items-center rounded-full border border-solid border-surface-5 bg-surface-4 px-1.5 py-1 leading-none text-xs text-secondary"
:aria-label="hiddenTooltip"
>
{{ formatMessage(messages.hiddenCount, { count: hiddenCount }) }}
</button>
<template #popper>
<div class="relative max-w-[22rem]">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-3 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="hiddenEntitiesScrollContainer"
class="flex flex-col gap-2 overflow-y-auto overscroll-contain py-0.5"
:style="{ maxHeight: hiddenEntitiesMaxHeight }"
@scroll="checkScrollState"
>
<EventEntityLink
v-for="entity in hiddenEntities"
:key="entity.id"
:entity="entity"
:text-weight="entityTextWeight"
class="min-w-0 pr-4"
stack-secondary
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-3 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</template>
</Tooltip>
</span>
</template>
<script setup lang="ts">
import { Tooltip } from 'floating-vue'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../../composables/i18n'
import { useScrollIndicator } from '../../../../composables/scroll-indicator'
import EventEntityLink from './EventEntityLink.vue'
import type { EventEntity } from './types'
const props = withDefaults(
defineProps<{
entities: EventEntity[]
limit?: number
singleLine?: boolean
entityTextWeight?: 'medium' | 'semibold'
}>(),
{
limit: 3,
singleLine: true,
entityTextWeight: 'medium',
},
)
const { formatMessage, locale } = useVIntl()
const hiddenEntitiesScrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(
hiddenEntitiesScrollContainer,
)
const TOOLTIP_VISIBLE_ROWS = 8
const TOOLTIP_ENTITY_HEIGHT_REM = 1.75
const TOOLTIP_ENTITY_GAP_REM = 0.5
const hiddenEntitiesMaxHeight = `${
TOOLTIP_VISIBLE_ROWS * TOOLTIP_ENTITY_HEIGHT_REM +
(TOOLTIP_VISIBLE_ROWS - 1) * TOOLTIP_ENTITY_GAP_REM
}rem`
const messages = defineMessages({
hiddenCount: {
id: 'servers.audit-log.event.entity-list.hidden-count',
defaultMessage: '+{count, number}',
},
})
const visibleEntities = computed(() => props.entities.slice(0, props.limit))
const hiddenEntities = computed(() => props.entities.slice(props.limit))
const hiddenCount = computed(() => hiddenEntities.value.length)
const hiddenTooltip = computed(() => {
void locale.value
return new Intl.ListFormat(locale.value, {
style: 'long',
type: 'conjunction',
}).format(hiddenEntities.value.map((entity) => entity.label))
})
</script>
<style lang="scss">
.v-popper__popper.v-popper--theme-dismissable-prompt.audit-log-entity-list-popper {
.v-popper__inner {
padding-right: 0 !important;
}
}
</style>
@@ -0,0 +1,23 @@
<template>
<span
ref="textRef"
v-tooltip="truncatedTooltip(textRef, tooltipText)"
class="max-w-full min-w-0 whitespace-normal break-words @[800px]:truncate @[800px]:whitespace-nowrap"
>
<slot>{{ text }}</slot>
</span>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { truncatedTooltip } from '#ui/utils/truncate'
const props = defineProps<{
text: string | number
tooltip?: string
}>()
const textRef = ref<HTMLElement | null>(null)
const tooltipText = computed(() => props.tooltip ?? String(props.text))
</script>
@@ -0,0 +1,64 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #file>
<EventEntityLink v-if="file" :entity="file" />
</template>
<template #from>
<EventEntityLink v-if="from" :entity="from" />
</template>
<template #to>
<EventEntityLink v-if="to" :entity="to" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: string
file?: EventEntity
from?: EventEntity
to?: EventEntity
}>()
const messages = defineMessages({
uploaded: {
id: 'servers.audit-log.event.file-uploaded',
defaultMessage: 'Uploaded file <file></file>',
},
deleted: {
id: 'servers.audit-log.event.file-deleted',
defaultMessage: 'Deleted file <file></file>',
},
edited: {
id: 'servers.audit-log.event.file-edited',
defaultMessage: 'Edited file <file></file>',
},
renamed: {
id: 'servers.audit-log.event.file-renamed',
defaultMessage: 'Renamed <from></from> to <to></to>',
},
changed: {
id: 'servers.audit-log.event.file-changed',
defaultMessage: 'Changed file <file></file>',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
uploaded: messages.uploaded,
deleted: messages.deleted,
edited: messages.edited,
renamed: messages.renamed,
}
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
</script>
@@ -0,0 +1,68 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #modpack>
<EventEntityLink v-if="modpack" :entity="modpack" />
</template>
<template #version>
<EventInlineText :text="versionLabel" class="align-middle font-mono text-secondary" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import EventInlineText from './EventInlineText.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: 'changed' | 'unlinked'
modpack?: EventEntity | null
versionLabel?: string | null
}>()
const messages = defineMessages({
changed: {
id: 'servers.audit-log.event.modpack-changed',
defaultMessage: 'Changed modpack',
},
changedToModpack: {
id: 'servers.audit-log.event.modpack-changed-to-modpack',
defaultMessage: 'Changed modpack to <modpack></modpack>',
},
changedToVersion: {
id: 'servers.audit-log.event.modpack-changed-to-version',
defaultMessage: 'Changed modpack to version <version></version>',
},
unlinked: {
id: 'servers.audit-log.event.modpack-unlinked',
defaultMessage: 'Unlinked modpack',
},
unlinkedModpack: {
id: 'servers.audit-log.event.modpack-unlinked-modpack',
defaultMessage: 'Unlinked modpack <modpack></modpack>',
},
unlinkedVersion: {
id: 'servers.audit-log.event.modpack-unlinked-version',
defaultMessage: 'Unlinked modpack version <version></version>',
},
})
const message = computed(() => {
if (props.kind === 'unlinked') {
if (props.modpack) return messages.unlinkedModpack
return props.versionLabel ? messages.unlinkedVersion : messages.unlinked
}
if (props.modpack) return messages.changedToModpack
return props.versionLabel ? messages.changedToVersion : messages.changed
})
const versionLabel = computed(() => props.versionLabel ?? '')
</script>
@@ -0,0 +1,35 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #port>
<span class="font-mono font-medium text-contrast">{{ port }}</span>
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
const props = defineProps<{
kind: 'added' | 'removed'
port: number
}>()
const messages = defineMessages({
added: {
id: 'servers.audit-log.event.port-allocation-added',
defaultMessage: 'Added port allocation <port></port>',
},
removed: {
id: 'servers.audit-log.event.port-allocation-removed',
defaultMessage: 'Removed port allocation <port></port>',
},
})
const message = computed(() => (props.kind === 'added' ? messages.added : messages.removed))
</script>
@@ -0,0 +1,124 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message">
<template #name>
<EventEntityLink :entity="nameEntity" />
</template>
<template #subdomain>
<EventEntityLink :entity="subdomainEntity" />
</template>
<template #specs>
<EventInlineText :text="specsLabel" class="align-middle font-medium text-contrast" />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import EventInlineText from './EventInlineText.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: 'name' | 'subdomain' | 'plan'
name?: string
subdomain?: string
newSpecs?: Record<string, unknown>
}>()
const { formatMessage, locale } = useVIntl()
const messages = defineMessages({
nameChanged: {
id: 'servers.audit-log.event.server-name-changed',
defaultMessage: 'Changed server name to <name></name>',
},
subdomainChanged: {
id: 'servers.audit-log.event.server-subdomain-changed',
defaultMessage: 'Changed server subdomain to <subdomain></subdomain>',
},
planChanged: {
id: 'servers.audit-log.event.server-plan-changed',
defaultMessage: 'Changed plan to <specs></specs>',
},
changed: {
id: 'servers.audit-log.event.server-metadata-changed',
defaultMessage: 'Changed server metadata',
},
cpuSpec: {
id: 'servers.audit-log.event.server-plan.cpu',
defaultMessage: '{count, plural, one {# CPU} other {# CPUs}}',
},
ramGb: {
id: 'servers.audit-log.event.server-plan.ram-gb',
defaultMessage: '{amount, number} GB RAM',
},
ramMb: {
id: 'servers.audit-log.event.server-plan.ram-mb',
defaultMessage: '{amount, number} MB RAM',
},
storageGb: {
id: 'servers.audit-log.event.server-plan.storage-gb',
defaultMessage: '{amount, number} GB storage',
},
storageMb: {
id: 'servers.audit-log.event.server-plan.storage-mb',
defaultMessage: '{amount, number} MB storage',
},
newPlan: {
id: 'servers.audit-log.event.server-plan.new-plan',
defaultMessage: 'new plan',
},
})
const kindMessages: Record<string, MessageDescriptor> = {
name: messages.nameChanged,
subdomain: messages.subdomainChanged,
plan: messages.planChanged,
}
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
const nameEntity = computed<EventEntity>(() => ({
id: props.name ?? '',
label: props.name ?? '',
}))
const subdomainEntity = computed<EventEntity>(() => ({
id: props.subdomain ?? '',
label: props.subdomain ?? '',
mono: true,
}))
const specsLabel = computed(() => {
void locale.value
const cpu = numberValue(props.newSpecs?.cpu)
const memory = numberValue(props.newSpecs?.memory_mb)
const storage = numberValue(props.newSpecs?.storage_mb)
const parts = []
if (cpu != null) parts.push(formatMessage(messages.cpuSpec, { count: cpu }))
if (memory != null) parts.push(formatMemoryMb(memory))
if (storage != null) parts.push(formatStorageMb(storage))
return parts.length > 0 ? parts.join(' / ') : formatMessage(messages.newPlan)
})
function numberValue(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function formatMemoryMb(value: number): string {
if (value >= 1024) return formatMessage(messages.ramGb, { amount: Math.round(value / 1024) })
return formatMessage(messages.ramMb, { amount: value })
}
function formatStorageMb(value: number): string {
if (value >= 1024) {
return formatMessage(messages.storageGb, { amount: Math.round(value / 1024) })
}
return formatMessage(messages.storageMb, { amount: value })
}
</script>
@@ -0,0 +1,25 @@
<template>
<BaseEvent>
<span class="mr-1 text-secondary">{{ formatMessage(messages.unknownEvent) }}</span>
<EventInlineText :text="rawAction" class="font-mono text-secondary" />
</BaseEvent>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '../../../../composables/i18n'
import BaseEvent from './BaseEvent.vue'
import EventInlineText from './EventInlineText.vue'
defineProps<{
rawAction: string
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
unknownEvent: {
id: 'servers.audit-log.event.unknown',
defaultMessage: 'Unknown event',
},
})
</script>
@@ -0,0 +1,123 @@
<template>
<BaseEvent>
<IntlFormatted :message-id="message" :values="{ permissions: permissionLabel }">
<template #target-user>
<EventEntityLink :entity="targetUser" />
</template>
<template #permission-label="{ children }">
<span
v-if="permissionRole"
class="inline-flex h-7 items-center rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none align-middle"
:class="roleClasses(permissionRole)"
>
<component :is="() => children" />
</span>
<component :is="() => children" v-else />
</template>
</IntlFormatted>
</BaseEvent>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import IntlFormatted from '../../../base/IntlFormatted.vue'
import { apiPermissionsToAccessRole } from '../permissions'
import type { ServerAccessRole } from '../types'
import BaseEvent from './BaseEvent.vue'
import EventEntityLink from './EventEntityLink.vue'
import type { EventEntity } from './types'
const props = defineProps<{
kind: 'invited' | 'invite_revoked' | 'permission_modified' | 'removed'
targetUser: EventEntity
permissions?: Archon.ServerUsers.v1.UserScope | null
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
invited: {
id: 'servers.audit-log.event.user-invited',
defaultMessage: 'Invited <target-user></target-user>',
},
invitedWithPermissions: {
id: 'servers.audit-log.event.user-invited-with-permissions',
defaultMessage:
'Invited <target-user></target-user> with <permission-label>{permissions}</permission-label>',
},
inviteRevoked: {
id: 'servers.audit-log.event.user-invite-revoked',
defaultMessage: 'Revoked invite for <target-user></target-user>',
},
permissionModified: {
id: 'servers.audit-log.event.user-permission-modified',
defaultMessage: 'Changed permissions for <target-user></target-user>',
},
permissionModifiedWithPermissions: {
id: 'servers.audit-log.event.user-permission-modified-with-permissions',
defaultMessage:
'Changed permissions for <target-user></target-user> to <permission-label>{permissions}</permission-label>',
},
removed: {
id: 'servers.audit-log.event.user-removed',
defaultMessage: 'Removed <target-user></target-user>',
},
ownerRole: {
id: 'servers.access-role.owner',
defaultMessage: 'Owner',
},
editorRole: {
id: 'servers.access-role.editor',
defaultMessage: 'Editor',
},
viewerRole: {
id: 'servers.access-role.viewer',
defaultMessage: 'Limited',
},
})
const roleMessages: Record<ServerAccessRole, MessageDescriptor> = {
owner: messages.ownerRole,
editor: messages.editorRole,
viewer: messages.viewerRole,
}
const message = computed(() => {
if (props.kind === 'invited') {
return permissionLabel.value ? messages.invitedWithPermissions : messages.invited
}
if (props.kind === 'invite_revoked') return messages.inviteRevoked
if (props.kind === 'permission_modified') {
return permissionLabel.value
? messages.permissionModifiedWithPermissions
: messages.permissionModified
}
return messages.removed
})
const permissionLabel = computed(() => {
const role = permissionRole.value
if (role) return formatMessage(roleMessages[role])
return ''
})
const permissionRole = computed(() =>
props.permissions == null || props.permissions === ''
? null
: apiPermissionsToAccessRole(props.permissions),
)
function roleClasses(role: ServerAccessRole): string {
switch (role) {
case 'owner':
return 'border-orange bg-highlight-orange text-orange'
case 'editor':
return 'border-green bg-highlight-green text-brand'
case 'viewer':
return 'border-blue bg-highlight-blue text-blue'
}
}
</script>
@@ -0,0 +1,16 @@
export { default as AddonEvent } from './AddonEvent.vue'
export { default as BackupEvent } from './BackupEvent.vue'
export { default as BaseEvent } from './BaseEvent.vue'
export { default as BasicStringEvent } from './BasicStringEvent.vue'
export { default as ConfigEvent } from './ConfigEvent.vue'
export { default as ConsoleEvent } from './ConsoleEvent.vue'
export { default as EventEntityLink } from './EventEntityLink.vue'
export { default as EventEntityList } from './EventEntityList.vue'
export { default as FileEvent } from './FileEvent.vue'
export { default as ModpackEvent } from './ModpackEvent.vue'
export { default as NetworkEvent } from './NetworkEvent.vue'
export * from './parser'
export { default as ServerMetaEvent } from './ServerMetaEvent.vue'
export * from './types'
export { default as UnknownEvent } from './UnknownEvent.vue'
export { default as UserAccessEvent } from './UserAccessEvent.vue'
@@ -0,0 +1,609 @@
import type { Archon } from '@modrinth/api-client'
import { PackageIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import AddonEvent from './AddonEvent.vue'
import BackupEvent from './BackupEvent.vue'
import BasicStringEvent from './BasicStringEvent.vue'
import ConfigEvent from './ConfigEvent.vue'
import ConsoleEvent from './ConsoleEvent.vue'
import FileEvent from './FileEvent.vue'
import ModpackEvent from './ModpackEvent.vue'
import NetworkEvent from './NetworkEvent.vue'
import ServerMetaEvent from './ServerMetaEvent.vue'
import type {
AuditActor,
AuditAddonEventItem,
AuditBackupEventItem,
AuditEventLookups,
AuditWorld,
BaseEventProps,
EventEntity,
ParsedAuditEvent,
} from './types'
import UnknownEvent from './UnknownEvent.vue'
import UserAccessEvent from './UserAccessEvent.vue'
const basicEvents = new Set([
'server_created',
'server_reallocated',
'server_repaired',
'server_reset',
'server_started',
'server_stopped',
'server_restarted',
'server_killed',
'sftp_login',
'console_cleared',
])
export function parseAuditEvent(
entry: Archon.Actions.v1.ActionEntry,
lookups: AuditEventLookups,
): ParsedAuditEvent {
const action = entry.action?.action || 'unknown'
const metadata = entry.action?.metadata
const base = baseProps(entry, lookups, action)
try {
if (basicEvents.has(action)) {
return parsed(BasicStringEvent, base, {}, actionSearchParts(action))
}
switch (action) {
case 'changed_server_name': {
const record = metadataRecord(metadata)
const name = stringField(record, 'name')
if (!name) return unknown(base, action)
return parsed(
ServerMetaEvent,
base,
{ kind: 'name', name },
actionSearchParts(action, name),
)
}
case 'changed_server_subdomain': {
const record = metadataRecord(metadata)
const subdomain = stringField(record, 'subdomain')
if (!subdomain) return unknown(base, action)
return parsed(ServerMetaEvent, base, { kind: 'subdomain', subdomain }, [
...actionSearchParts(action),
subdomain,
])
}
case 'server_plan_changed': {
const record = metadataRecord(metadata)
const newSpecs = objectField(record, 'new_specs')
if (!newSpecs) return unknown(base, action)
return parsed(ServerMetaEvent, base, { kind: 'plan', newSpecs }, [
...actionSearchParts(action),
...Object.values(newSpecs).map(String),
])
}
case 'user_invited':
case 'user_permission_modified': {
const actionMetadata = userPermissionsActionMetadata(metadataRecord(metadata))
if (!actionMetadata) return unknown(base, action)
const kind = action === 'user_invited' ? 'invited' : 'permission_modified'
const targetUser = userEntity(actionMetadata.user_id, lookups.users)
return parsed(
UserAccessEvent,
base,
{ kind, targetUser, permissions: actionMetadata.permissions },
[...actionSearchParts(action), targetUser.label, actionMetadata.permissions],
)
}
case 'user_invite_revoked':
case 'user_removed': {
const record = metadataRecord(metadata)
const userId = stringField(record, 'user_id')
if (!userId) return unknown(base, action)
const kind = action === 'user_invite_revoked' ? 'invite_revoked' : 'removed'
const targetUser = userEntity(userId, lookups.users)
return parsed(UserAccessEvent, base, { kind, targetUser }, [
...actionSearchParts(action),
targetUser.label,
])
}
case 'addon_added':
case 'addon_disabled':
case 'addon_enabled':
case 'addon_deleted':
case 'addon_updated': {
const addons = addonList(metadataRecord(metadata), lookups)
if (!addons) return unknown(base, action)
const kind = action.replace('addon_', '')
return parsed(AddonEvent, base, { kind, addons }, [
...actionSearchParts(action),
...addons.flatMap((addon) => [
addon.project.label,
addon.addonId,
addon.versionId,
addon.versionLabel,
]),
])
}
case 'addon_uploaded': {
const fileNames = stringArrayField(metadataRecord(metadata), 'file_names')
if (!fileNames) return unknown(base, action)
const files = fileNames.map((name) => fileEntity(name, lookups.serverId, false))
return parsed(AddonEvent, base, { kind: 'uploaded', fileNames: files }, [
...actionSearchParts(action),
...fileNames,
])
}
case 'modpack_changed': {
const record = metadataRecord(metadata)
const modpack = modpackEntityFromMetadata(record, lookups)
const versionLabel = modpack ? null : modpackVersionLabelFromMetadata(record, lookups)
return parsed(ModpackEvent, base, { kind: 'changed', modpack, versionLabel }, [
...actionSearchParts(action),
modpack?.id,
modpack?.label,
modpack?.secondaryLabel,
versionLabel,
])
}
case 'modpack_unlinked': {
const record = metadataRecord(metadata)
const modpack = modpackEntityFromMetadata(record, lookups)
const versionLabel = modpack ? null : modpackVersionLabelFromMetadata(record, lookups)
return parsed(ModpackEvent, base, { kind: 'unlinked', modpack, versionLabel }, [
...actionSearchParts(action),
modpack?.id,
modpack?.label,
modpack?.secondaryLabel,
versionLabel,
])
}
case 'port_allocation_added':
case 'port_allocation_removed': {
const port = numberField(metadataRecord(metadata), 'port')
if (port == null) return unknown(base, action)
return parsed(
NetworkEvent,
base,
{ kind: action === 'port_allocation_added' ? 'added' : 'removed', port },
[...actionSearchParts(action), String(port)],
)
}
case 'loader_version_edited': {
const record = metadataRecord(metadata)
if (!record || !('new_version' in record)) return unknown(base, action)
const newLoader = record.new_loader == null ? null : valueToString(record.new_loader)
const newVersion = record.new_version == null ? null : valueToString(record.new_version)
return parsed(ConfigEvent, base, { kind: 'loader_version', newLoader, newVersion }, [
...actionSearchParts(action),
newLoader,
newVersion,
])
}
case 'game_version_edited': {
const newVersion = stringField(metadataRecord(metadata), 'new_version')
if (!newVersion) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'game_version', newVersion }, [
...actionSearchParts(action),
newVersion,
])
}
case 'server_properties_modified': {
const properties = objectField(metadataRecord(metadata), 'properties')
if (!properties) return unknown(base, action)
const items = Object.entries(properties).map(
([key, value]): EventEntity => ({
id: key,
label: `${key}: ${valueToString(value) ?? ''}`,
mono: true,
}),
)
return parsed(ConfigEvent, base, { kind: 'properties', properties: items }, [
...actionSearchParts(action),
...items.map((item) => item.label),
])
}
case 'startup_command_modified': {
const command = stringField(metadataRecord(metadata), 'command')
if (!command) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'startup_command', command }, [
...actionSearchParts(action),
command,
])
}
case 'java_runtime_modified': {
const vendor = stringField(metadataRecord(metadata), 'vendor')
if (!vendor) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'java_runtime', vendor }, [
...actionSearchParts(action),
vendor,
])
}
case 'java_version_modified': {
const version = numberField(metadataRecord(metadata), 'version')
if (version == null) return unknown(base, action)
return parsed(ConfigEvent, base, { kind: 'java_version', version }, [
...actionSearchParts(action),
String(version),
])
}
case 'file_uploaded':
case 'file_deleted':
case 'file_edited': {
const path = stringField(metadataRecord(metadata), 'path')
if (!path) return unknown(base, action)
const kind = action.replace('file_', '')
return parsed(FileEvent, base, { kind, file: fileEntity(path, lookups.serverId) }, [
...actionSearchParts(action),
path,
])
}
case 'file_renamed': {
const record = metadataRecord(metadata)
const from = stringField(record, 'from')
const to = stringField(record, 'to')
if (!from || !to) return unknown(base, action)
return parsed(
FileEvent,
base,
{
kind: 'renamed',
from: fileEntity(from, lookups.serverId),
to: fileEntity(to, lookups.serverId),
},
[...actionSearchParts(action), from, to],
)
}
case 'console_command_executed': {
const command = stringField(metadataRecord(metadata), 'command')
if (!command) return unknown(base, action)
return parsed(ConsoleEvent, base, { command }, [...actionSearchParts(action), command])
}
case 'backup_created':
case 'backup_restored':
case 'backup_deleted': {
const id = stringField(metadataRecord(metadata), 'id')
if (!id) return unknown(base, action)
const kind = action.replace('backup_', '')
const backup = backupEntity(id, lookups)
return parsed(BackupEvent, base, { kind, backup: backup ?? undefined, backupId: id }, [
...actionSearchParts(action),
backup?.label,
id,
])
}
case 'backup_renamed': {
const record = metadataRecord(metadata)
const id = stringField(record, 'id')
const from = stringField(record, 'from')
const to = stringField(record, 'to')
if (!id || !from || !to) return unknown(base, action)
const backup = backupEntity(id, lookups)
return parsed(
BackupEvent,
base,
{ kind: 'renamed', backup: backup ?? undefined, backupId: id, from, to },
[...actionSearchParts(action), backup?.label, from, to, id],
)
}
default:
return unknown(base, action)
}
} catch {
return unknown(base, action)
}
}
function baseProps(
entry: Archon.Actions.v1.ActionEntry,
lookups: AuditEventLookups,
action: string,
): BaseEventProps {
return {
action,
timestamp: entry.timestamp,
actor: actorFromEntry(entry.actor, lookups.users),
world: worldFromId(entry.world_id ?? null, lookups.worldById),
}
}
function parsed(
component: Component,
base: BaseEventProps,
props: Record<string, unknown>,
searchParts: unknown[],
): ParsedAuditEvent {
return {
key: base.action,
component,
props: { ...base, ...props },
searchText: searchParts
.filter((part): part is string => typeof part === 'string' && part.length > 0)
.join(' ')
.toLowerCase(),
}
}
function unknown(base: BaseEventProps, rawAction: string): ParsedAuditEvent {
return parsed(UnknownEvent, base, { rawAction }, [rawAction])
}
function actionSearchParts(action: string, ...extra: unknown[]): unknown[] {
return [action, action.replaceAll('_', ' '), ...extra]
}
function actorFromEntry(
actor: Archon.Actions.v1.ActionUser,
users: Record<string, Archon.Actions.v1.UserResp>,
): AuditActor {
if (actor.type === 'support') {
const user = actor.user_id ? users[actor.user_id] : undefined
return {
id: 'support',
username: user?.username ? `Support (${user.username})` : 'support',
}
}
const user = users[actor.user_id]
return {
id: actor.user_id,
username: user?.username ?? actor.user_id,
avatarUrl: user?.avatar_url || undefined,
profilePath: user?.username ? `/user/${encodeURIComponent(user.username)}` : undefined,
}
}
function userPermissionsActionMetadata(
record: Record<string, unknown> | null,
): Archon.Actions.v1.UserPermissionsActionMetadata | null {
const userId = stringField(record, 'user_id')
if (!userId) return null
return {
user_id: userId,
permissions: permissionField(record?.permissions),
}
}
function permissionField(value: unknown): Archon.ServerUsers.v1.UserScope | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') return value.trim() || null
if (Array.isArray(value)) {
const permissions = value
.filter((permission): permission is string => typeof permission === 'string')
.map((permission) => permission.trim())
.filter(Boolean)
return permissions.length > 0 ? permissions.join(' | ') : null
}
return null
}
function worldFromId(
worldId: string | null,
worldById: Map<string, AuditWorld>,
): AuditWorld | null {
if (!worldId) return null
return worldById.get(worldId) ?? { id: worldId, name: worldId }
}
function userEntity(
userId: string,
users: Record<string, Archon.Actions.v1.UserResp>,
): EventEntity {
const user = users[userId]
const label = user?.username ?? userId
return {
id: userId,
label,
iconUrl: user?.avatar_url || undefined,
iconShape: 'circle',
to: user?.username ? `/user/${encodeURIComponent(user.username)}` : undefined,
}
}
function addonList(
record: Record<string, unknown> | null,
lookups: AuditEventLookups,
): AuditAddonEventItem[] | null {
const list = arrayField(record, 'addons')
if (!list) return null
const addons: AuditAddonEventItem[] = []
for (const item of list) {
const addonRecord = metadataRecord(item)
const addonId = stringField(addonRecord, 'addon_id')
const versionId = stringField(addonRecord, 'version_id')
if (!addonId || !versionId) return null
addons.push(addonEntity(addonId, versionId, lookups.addons, lookups.versions))
}
return addons
}
function addonEntity(
addonId: string,
versionId: string,
addons: Record<string, Archon.Actions.v1.AddonResp>,
versions: Record<string, Archon.Actions.v1.VersionResp>,
): AuditAddonEventItem {
const addon = addons[addonId]
const versionLabel = resolveVersionLabel(versionId, versions)
const projectIdOrSlug = addon?.slug || addonId
return {
addonId,
versionId,
versionLabel,
project: {
id: addonId,
label: addon?.title || shortId(addonId),
secondaryLabel: versionLabel,
icon: PackageIcon,
iconUrl: addon?.icon_url || undefined,
iconShape: 'square',
to: `/project/${encodeURIComponent(projectIdOrSlug)}/version/${encodeURIComponent(versionId)}`,
},
}
}
function resolveVersionLabel(
versionId: string,
versions: Record<string, Archon.Actions.v1.VersionResp>,
): string {
const version = versions[versionId]
return version?.version_number || version?.name || shortId(versionId)
}
function backupEntity(id: string, lookups: AuditEventLookups): AuditBackupEventItem | null {
const backup = lookups.backupById.get(id)
if (!backup) return null
return {
id,
backupId: id,
found: true,
label: backup.name,
to: {
path: `/hosting/manage/${lookups.serverId}/backups`,
query: { backup: id },
},
}
}
function fileEntity(path: string, serverId: string, link = true): EventEntity {
return {
id: path,
label: path,
mono: true,
to: link
? {
path: `/hosting/manage/${serverId}/files`,
query: {
path: parentPath(path),
editing: path,
},
}
: undefined,
}
}
function parentPath(path: string): string {
const normalized = path.startsWith('/') ? path : `/${path}`
const lastSlash = normalized.lastIndexOf('/')
if (lastSlash <= 0) return '/'
return normalized.slice(0, lastSlash)
}
function metadataRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function objectField(
record: Record<string, unknown> | null,
key: string,
): Record<string, unknown> | null {
return metadataRecord(record?.[key])
}
function arrayField(record: Record<string, unknown> | null, key: string): unknown[] | null {
const value = record?.[key]
return Array.isArray(value) ? value : null
}
function stringArrayField(record: Record<string, unknown> | null, key: string): string[] | null {
const array = arrayField(record, key)
if (!array || !array.every((item) => typeof item === 'string')) return null
return array
}
function modpackEntityFromMetadata(
record: Record<string, unknown> | null,
lookups: AuditEventLookups,
): EventEntity | null {
const spec = metadataRecord(record?.spec)
if (!spec) return null
const platform = stringField(spec, 'platform')
if (platform === 'modrinth') {
const projectId = stringField(spec, 'project_id')
const versionId = stringField(spec, 'version_id')
if (!projectId && !versionId) return null
const project = projectId ? lookups.addons[projectId] : undefined
const versionLabel = versionId ? resolveVersionLabel(versionId, lookups.versions) : undefined
const projectIdOrSlug = project?.slug || projectId
const label = project?.title || (projectId ? shortId(projectId) : versionLabel)
return {
id: projectId || versionId || 'modrinth',
label: label || 'Modrinth modpack',
secondaryLabel: versionLabel,
icon: PackageIcon,
iconUrl: project?.icon_url || undefined,
iconShape: 'square',
to: projectIdOrSlug
? versionId
? `/project/${encodeURIComponent(projectIdOrSlug)}/version/${encodeURIComponent(versionId)}`
: `/project/${encodeURIComponent(projectIdOrSlug)}`
: undefined,
title: project?.title ? undefined : projectId || versionId || undefined,
}
}
if (platform === 'local_file') {
const filename = stringField(spec, 'filename')
const name = stringField(spec, 'name')
const versionId = stringField(spec, 'version_id')
if (!filename && !name && !versionId) return null
return {
id: filename || name || versionId || 'local-file',
label: name || filename || versionId || 'Local modpack',
secondaryLabel: name && filename ? filename : versionId || undefined,
icon: PackageIcon,
iconShape: 'square',
mono: !name,
title: filename || name || undefined,
}
}
return null
}
function modpackVersionLabelFromMetadata(
record: Record<string, unknown> | null,
lookups: AuditEventLookups,
): string | null {
const direct = valueToString(record?.new_version)
if (direct != null) return direct
const spec = metadataRecord(record?.spec)
if (!spec) return null
const versionId = valueToString(spec.version_id)
if (versionId != null) return resolveVersionLabel(versionId, lookups.versions)
if (spec.platform === 'local_file') {
return stringField(spec, 'name') ?? stringField(spec, 'filename')
}
return null
}
function stringField(record: Record<string, unknown> | null, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function numberField(record: Record<string, unknown> | null, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function valueToString(value: unknown): string | null {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return null
}
function shortId(id: string): string {
if (id.length <= 12) return id
return id.slice(0, 8)
}
@@ -0,0 +1,69 @@
import type { Archon } from '@modrinth/api-client'
import type { Component } from 'vue'
export interface AuditActor {
id: string
username: string
avatarUrl?: string
profilePath?: string
}
export interface AuditWorld {
id: string
name: string
}
export interface BaseEventProps {
action: string
timestamp: string
actor: AuditActor
world: AuditWorld | null
}
export type EventRoute =
| string
| {
path: string
query?: Record<string, string | undefined>
}
export interface EventEntity {
id: string
label: string
secondaryLabel?: string
to?: EventRoute
icon?: Component
iconUrl?: string | null
iconShape?: 'circle' | 'square'
mono?: boolean
muted?: boolean
title?: string
}
export interface AuditAddonEventItem {
addonId: string
versionId: string
project: EventEntity
versionLabel: string
}
export interface AuditBackupEventItem extends EventEntity {
backupId: string
found: boolean
}
export interface ParsedAuditEvent {
key: string
component: Component
props: BaseEventProps & Record<string, unknown>
searchText: string
}
export interface AuditEventLookups {
serverId: string
users: Record<string, Archon.Actions.v1.UserResp>
addons: Record<string, Archon.Actions.v1.AddonResp>
versions: Record<string, Archon.Actions.v1.VersionResp>
worldById: Map<string, AuditWorld>
backupById: Map<string, Archon.Backups.v1.Backup>
}
@@ -0,0 +1,7 @@
export { default as AccessTable } from './AccessTable.vue'
export { default as AuditLogTable } from './AuditLogTable.vue'
export * from './events'
export { default as GrantAccessModal } from './GrantAccessModal.vue'
export * from './permissions'
export { default as RemoveAccessModal } from './RemoveAccessModal.vue'
export * from './types'
@@ -0,0 +1,23 @@
import type { Archon } from '@modrinth/api-client'
import { hasServerPermission } from '../../../composables/server-permissions'
import type { ServerAccessRole } from './types'
export function apiPermissionsToAccessRole(
permissions: Archon.ServerUsers.v1.UserScope,
): ServerAccessRole {
if (hasServerPermission(permissions, 'SERVER_ADMIN')) {
return 'owner'
}
if (
hasServerPermission(permissions, 'EXEC_COMMANDS') ||
hasServerPermission(permissions, 'FILES_WRITE') ||
hasServerPermission(permissions, 'SETUP') ||
hasServerPermission(permissions, 'BACKUPS') ||
hasServerPermission(permissions, 'ADVANCED') ||
hasServerPermission(permissions, 'RESET_SERVER')
) {
return 'editor'
}
return 'viewer'
}
@@ -0,0 +1,57 @@
import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
export interface ServerAccessUser extends AuditActor {
id: string
username: string
avatarUrl?: string
}
export interface ServerAccessMember {
id: string
user: ServerAccessUser
role: ServerAccessRole
joinedAt: string | null
inviteResendAvailableAt?: string | null
pending?: boolean
isOwner?: boolean
}
export interface ServerAuditLogEntry {
id: string
actor: AuditActor
world: AuditWorld | null
event: ParsedAuditEvent
timestamp: string
}
export interface ServerAuditLogFilters {
userId: string | null
worldId: string | null
}
export interface ServerAccessRoleOption {
value: ServerAccessRole
label: string
description?: string
}
export interface ServerAccessInviteSuggestion {
id: string
username: string
avatarUrl?: string
email?: string
}
export interface GrantServerAccessPayload {
target: string
user: ServerAccessInviteSuggestion
role: Exclude<ServerAccessRole, 'owner'>
addAsFriend: boolean
}
export interface ServerListingOwner {
username: string
avatarUrl?: string
}
@@ -32,6 +32,8 @@ defineProps<{
item: BackupAdmonitionEntry
dismissible: boolean
cancelling: boolean
canManageBackups?: boolean
permissionDeniedMessage?: string
}>()
defineEmits<{
@@ -278,12 +280,24 @@ function getDescription(item: BackupAdmonitionEntry): string {
</div>
<template #top-right-actions>
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
<button
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="cancelling || canManageBackups === false"
@click="$emit('cancel')"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
<button class="!border" type="button" @click="$emit('retry')">
<button
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="canManageBackups === false"
@click="$emit('retry')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
@@ -25,7 +25,13 @@
</span>
<template v-if="op.id" #top-right-actions>
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
<button class="!border" type="button" @click="ctx.dismissOperation(op.id!, 'cancel')">
<button
v-tooltip="!canWriteFiles ? permissionDeniedMessage : undefined"
class="!border"
type="button"
:disabled="!canWriteFiles"
@click="cancelOperation"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
@@ -41,6 +47,7 @@ import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
@@ -55,6 +62,7 @@ const props = defineProps<{
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
const { canWriteFiles, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
extracting: {
@@ -97,4 +105,9 @@ const title = computed(() => {
}
return formatMessage(messages.extracting, { source: sourceName.value })
})
function cancelOperation() {
if (!canWriteFiles.value || !props.op.id) return
ctx.dismissOperation(props.op.id, 'cancel')
}
</script>
@@ -12,6 +12,7 @@ import InstallingBanner, {
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerPermissions } from '#ui/composables/server-permissions'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -32,6 +33,7 @@ const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const ctx = injectModrinthServerContext()
const route = useRoute()
const { canSetup, canManageBackups, permissionDeniedMessage } = useServerPermissions()
const { activeOperations, backups, progressFor, invalidate } = useServerBackupsQueue(
computed(() => ctx.serverId),
@@ -311,6 +313,7 @@ async function onBackupDismiss(item: BackupAdmonitionEntry) {
}
async function onBackupCancel(item: BackupAdmonitionEntry) {
if (!canManageBackups.value) return
if (cancellingIds.has(item.key)) return
cancellingIds.add(item.key)
try {
@@ -337,6 +340,7 @@ async function onBackupCancel(item: BackupAdmonitionEntry) {
}
async function onBackupRetry(item: BackupAdmonitionEntry) {
if (!canManageBackups.value) return
await client.archon.backups_queue_v1.retry(ctx.serverId, ctx.worldId.value!, item.backupId)
dismissedIds.add(item.key)
await invalidate()
@@ -402,6 +406,8 @@ function onContentErrorDismiss() {
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
:retry-disabled="!canSetup"
:retry-disabled-tooltip="permissionDeniedMessage"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
@@ -422,6 +428,8 @@ function onContentErrorDismiss() {
:item="item.entry"
:dismissible="dismissible"
:cancelling="cancellingIds.has(item.entry.key)"
:can-manage-backups="canManageBackups"
:permission-denied-message="permissionDeniedMessage"
@dismiss="onBackupDismiss(item.entry)"
@cancel="onBackupCancel(item.entry)"
@retry="onBackupRetry(item.entry)"
@@ -53,7 +53,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
<button
v-tooltip="createDisabledTooltip"
:disabled="createDisabled"
@click="createBackup"
>
<PlusIcon />
Create backup
</button>
@@ -69,23 +73,35 @@ import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const props = withDefaults(
defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
canCreate?: boolean
permissionDeniedMessage?: string
}>(),
{
backups: undefined,
canCreate: true,
permissionDeniedMessage: undefined,
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
@@ -109,6 +125,14 @@ const nameExists = computed(() => {
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const createDisabled = computed(
() => createMutation.isPending.value || nameExists.value || !props.canCreate,
)
const createDisabledTooltip = computed(() =>
props.canCreate
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
const focusInput = () => {
nextTick(() => {
@@ -129,6 +153,7 @@ const hideModal = () => {
}
const createBackup = () => {
if (!props.canCreate) return
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
isRateLimited.value = false
@@ -67,7 +67,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirmDelete">
<button
v-tooltip="deleteDisabledTooltip"
:disabled="!props.canDelete"
@click="confirmDelete"
>
<TrashIcon />
{{ formatMessage(messages.confirm, { count }) }}
</button>
@@ -92,6 +96,17 @@ import BackupItem from './BackupItem.vue'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
canDelete?: boolean
permissionDeniedMessage?: string
}>(),
{
canDelete: true,
permissionDeniedMessage: undefined,
},
)
const emit = defineEmits<{
(e: 'delete', backup: Archon.BackupsQueue.v1.BackupQueueBackup | undefined): void
(e: 'bulk-delete', backups: Archon.BackupsQueue.v1.BackupQueueBackup[]): void
@@ -133,6 +148,11 @@ const count = computed(() => (isBulk.value ? bulkBackups.value.length : 1))
const displayBackups = computed(() =>
isBulk.value ? bulkBackups.value : singleBackup.value ? [singleBackup.value] : [],
)
const deleteDisabledTooltip = computed(() =>
props.canDelete
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
singleBackup.value = backup
@@ -149,6 +169,7 @@ function showBulk(backups: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
}
function confirmDelete() {
if (!props.canDelete) return
modal.value?.hide()
if (isBulk.value) {
emit('bulk-delete', bulkBackups.value)
@@ -38,7 +38,10 @@ const props = withDefaults(
showCopyIdAction?: boolean
showDebugInfo?: boolean
restoreDisabled?: string
writeDisabled?: boolean
writeDisabledTooltip?: string
selected?: boolean
highlighted?: boolean
}>(),
{
preview: false,
@@ -47,7 +50,10 @@ const props = withDefaults(
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
writeDisabled: false,
writeDisabledTooltip: undefined,
selected: false,
highlighted: false,
},
)
@@ -60,6 +66,12 @@ const backupIcon = computed(() => {
return UserRoundIcon
})
const itemBorderClass = computed(() => {
if (props.selected) return 'border-brand-green'
if (props.highlighted) return 'border-purple backup-item-highlighted'
return 'border-transparent'
})
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
@@ -81,13 +93,20 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
disabled: !props.kyrosUrl || !props.jwt,
})
options.push({ id: 'rename', action: () => emit('rename') })
options.push({
id: 'rename',
action: () => emit('rename'),
disabled: props.writeDisabled,
tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
})
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: props.writeDisabled,
tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
})
return options
@@ -123,7 +142,7 @@ const messages = defineMessages({
<template>
<div
class="flex items-center gap-4 rounded-[20px] border border-solid bg-surface-3 p-4 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.3),0px_1px_3px_0px_rgba(0,0,0,0.15)]"
:class="props.selected ? 'border-brand-green' : 'border-transparent'"
:class="itemBorderClass"
>
<div class="flex min-w-0 flex-1 items-center gap-4">
<!-- Icon tile -->
@@ -215,3 +234,25 @@ const messages = defineMessages({
}}</pre>
</div>
</template>
<style scoped>
@keyframes backup-item-highlight-pulse {
0%,
100% {
box-shadow:
0 0 0 0 var(--color-purple-highlight),
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 0px rgba(0, 0, 0, 0.15);
}
50% {
box-shadow:
0 0 0 3px var(--color-purple-highlight),
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 0px rgba(0, 0, 0, 0.15);
}
}
.backup-item-highlighted {
animation: backup-item-highlight-pulse 1.25s ease-in-out infinite;
}
</style>
@@ -29,7 +29,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
<button
v-tooltip="renameDisabledTooltip"
:disabled="renameDisabled"
@click="renameBackup"
>
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
Renaming...
@@ -51,23 +55,35 @@ import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
}>()
const props = withDefaults(
defineProps<{
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
canRename?: boolean
permissionDeniedMessage?: string
}>(),
{
backups: undefined,
canRename: true,
permissionDeniedMessage: undefined,
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
@@ -99,6 +115,14 @@ const nameExists = computed(() => {
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const renameDisabled = computed(
() => renameMutation.isPending.value || nameExists.value || !props.canRename,
)
const renameDisabledTooltip = computed(() =>
props.canRename
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
const backupNumber = computed(
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
@@ -124,6 +148,7 @@ function hide() {
}
const renameBackup = () => {
if (!props.canRename) return
if (!currentBackup.value) {
addNotification({
type: 'error',
@@ -24,7 +24,11 @@
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="isRestoring || ctx.isServerRunning.value" @click="restoreBackup">
<button
v-tooltip="restoreDisabledTooltip"
:disabled="restoreDisabled"
@click="restoreBackup"
>
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
<RotateCounterClockwiseIcon v-else />
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
@@ -39,23 +43,37 @@
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = withDefaults(
defineProps<{
canRestore?: boolean
permissionDeniedMessage?: string
}>(),
{
canRestore: true,
permissionDeniedMessage: undefined,
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
function safetyBackupName(backupName: string) {
@@ -72,6 +90,14 @@ const restoreMutation = useMutation({
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
const isRestoring = ref(false)
const restoreDisabled = computed(
() => isRestoring.value || ctx.isServerRunning.value || !props.canRestore,
)
const restoreDisabledTooltip = computed(() =>
props.canRestore
? undefined
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup
@@ -79,7 +105,7 @@ function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
}
const restoreBackup = () => {
if (!currentBackup.value || isRestoring.value) {
if (!props.canRestore || !currentBackup.value || isRestoring.value) {
if (!currentBackup.value) {
addNotification({
type: 'error',
@@ -3,17 +3,21 @@
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="'Edit icon'"
v-tooltip="editIconTooltip"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:disabled="isIconActionLoading"
:disabled="isIconActionDisabled"
:options="[
{
id: 'upload',
action: () => triggerFileInput(),
disabled: !props.canEdit,
tooltip: !props.canEdit ? editIconTooltip : undefined,
},
{
id: 'sync',
action: () => resetIcon(),
disabled: !props.canEdit,
tooltip: !props.canEdit ? editIconTooltip : undefined,
},
]"
>
@@ -47,19 +51,39 @@ import { computed, ref } from 'vue'
import { OverflowMenu, ServerIcon } from '#ui/components'
import { useServerImage } from '#ui/composables'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const props = withDefaults(
defineProps<{
canEdit?: boolean
permissionDeniedMessage?: string
}>(),
{
canEdit: true,
permissionDeniedMessage: undefined,
},
)
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server } = injectModrinthServerContext()
const queryClient = useQueryClient()
const isUploadingIcon = ref(false)
const isSyncingIcon = ref(false)
const isIconActionLoading = computed(() => isUploadingIcon.value || isSyncingIcon.value)
const isIconActionDisabled = computed(() => isIconActionLoading.value || !props.canEdit)
const editIconTooltip = computed(() =>
props.canEdit
? 'Edit icon'
: (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
)
const {
image: displayIcon,
@@ -84,7 +108,7 @@ function isNotFound(error: unknown): boolean {
}
const uploadFile = async (e: Event) => {
if (isIconActionLoading.value) return
if (isIconActionDisabled.value) return
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) {
@@ -194,7 +218,7 @@ const uploadFile = async (e: Event) => {
}
const resetIcon = async () => {
if (isIconActionLoading.value) return
if (isIconActionDisabled.value) return
isSyncingIcon.value = true
try {
@@ -234,7 +258,7 @@ const resetIcon = async () => {
}
const triggerFileInput = () => {
if (isIconActionLoading.value) return
if (isIconActionDisabled.value) return
const input = document.createElement('input')
input.type = 'file'
@@ -224,9 +224,10 @@
<script setup lang="ts">
import { LoaderIcon } from '@modrinth/assets'
import type { Loaders } from '@modrinth/utils'
import type { ServerLoader } from '#ui/utils/loaders'
defineProps<{
loader: Loaders
loader: ServerLoader
}>()
</script>
@@ -1,3 +1,4 @@
export * from './access'
export * from './admonitions'
export * from './backups'
export * from './flows'
@@ -38,6 +38,7 @@
import { computed } from 'vue'
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
import type { ServerLoader } from '#ui/utils/loaders'
import AutoLink from '../../base/AutoLink.vue'
import LoaderIcon from '../icons/LoaderIcon.vue'
@@ -45,7 +46,7 @@ import Separator from './Separator.vue'
defineProps<{
noSeparator?: boolean
loader?: 'Fabric' | 'Quilt' | 'Forge' | 'NeoForge' | 'Paper' | 'Spigot' | 'Bukkit' | 'Vanilla'
loader?: ServerLoader
loaderVersion?: string
isLink?: boolean
}>()
@@ -36,13 +36,29 @@
</div>
<Avatar v-else src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="z-10" />
<div class="z-10 ml-4 flex min-w-0 flex-col gap-1.5">
<div class="flex flex-row items-center gap-2">
<div class="flex flex-row items-center gap-2.5">
<h2
class="m-0 truncate text-xl font-bold text-contrast"
:class="{ 'opacity-50': isDisabled }"
>
{{ name }}
</h2>
<div
v-if="owner"
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
class="flex min-w-0 items-center gap-1.5 rounded-full bg-surface-4/80 px-2 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
:class="{ 'opacity-50': isDisabled }"
>
<Avatar
:src="owner.avatarUrl"
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
:tint-by="owner.username"
size="1.25rem"
circle
no-shadow
/>
<span class="max-w-32 truncate">{{ owner.username }}</span>
</div>
<span class="truncate" :class="{ 'opacity-50': isDisabled }">
<IntlFormatted
@@ -158,6 +174,7 @@ import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import CopyCode from '../../base/CopyCode.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
import type { ServerListingOwner } from '../access/types'
import ServerInfoLabels from '../labels/ServerInfoLabels.vue'
import MedalBackgroundImage from './MedalBackgroundImage.vue'
@@ -176,6 +193,7 @@ type MedalServerListingProps = {
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
medal_expires?: string
owner?: ServerListingOwner
}
const props = defineProps<MedalServerListingProps>()
@@ -222,6 +240,14 @@ const messages = defineMessages({
id: 'servers.medal-listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
ownerTooltip: {
id: 'servers.medal-listing.owner-tooltip',
defaultMessage: 'Owned by {username}',
},
ownerAvatarAlt: {
id: 'servers.medal-listing.owner-avatar-alt',
defaultMessage: "{username}'s avatar",
},
newServerLabel: {
id: 'servers.medal-listing.new-server-label',
defaultMessage: 'New server',
@@ -21,6 +21,8 @@
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
:primary-tooltip="busyTooltip"
:dropdown-tooltip="busyTooltip"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
@@ -37,6 +39,7 @@
:primary-disabled="true"
:dropdown-disabled="!canKill"
:primary-muted="true"
:dropdown-tooltip="busyTooltip"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
@@ -1,6 +1,7 @@
import { computed, type Ref } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -15,6 +16,7 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(
() =>
@@ -34,20 +36,27 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const showStopSplit = computed(() => isRunning.value || isStarting.value || isStopping.value)
const showRestartButton = computed(() => isRunning.value || isStarting.value)
const isBlockedByPropsOrBusy = computed(
() => Boolean(options?.disabled?.value) || busyReasons.value.length > 0,
const isBlockedByPropsBusyOrPermission = computed(
() =>
!canUsePowerActions.value ||
Boolean(options?.disabled?.value) ||
busyReasons.value.length > 0,
)
const busyTooltip = computed(() => {
if (!canUsePowerActions.value) return permissionDeniedMessage.value
if (isStarting.value) return 'Your server is starting'
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
const canTakeAction = computed(() => !isTransitioning.value && !isBlockedByPropsOrBusy.value)
const canTakeAction = computed(
() => !isTransitioning.value && !isBlockedByPropsBusyOrPermission.value,
)
const canKill = computed(
() =>
!isBlockedByPropsOrBusy.value && (isStopping.value || isRunning.value || isStarting.value),
!isBlockedByPropsBusyOrPermission.value &&
(isStopping.value || isRunning.value || isStarting.value),
)
const primaryActionText = computed(() => {