mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 10:04:52 +00:00
feat: hosting access tab (#5995)
* feat: implement access tab with dummy data * fix: spacing * feat: qa * feat: implement backend * qa: qa pass * feat: fix user "search" * fix: lint * feat: change to bitfield * feat: fix fields * fix: lint * fix: lint * feat: hook up api * feat: fix permissions * feat: audit log table event start * feat: better mobile mode for audit log table * feat: i18n * feat: qa * feat: enforce permissions * feat: email template start * feat: qa * fix: tooltip bug * feat: qa * impl: sse support in api-client * feat: sse impl * fix: desync path * feat: time frame picker from analytics * feat: QA * fix: spacing * fix: permisison audit log entries * fix: hosting manage page shared server detection * fix: lint * feat: qa + lint * feat: audit log table sort by time * feat: finish frontend panel stuff * fix: lint * fix: backend alignment * fix: lint * fix: supress friend errors * feat: qa * fix: qa * fix: lint * fix: utils barrel * fix: safari cookies in dev * fix: pin nuxt * feat: fixes + notif fix * fix: notifications * feat: qa * fix: notification sync not happening immediately * fix: qa * fix: qa * feat: qa * blog + prepr * feat: toast shit * blog images * thumbnail update one last time * prepr * feat: use reinvite route * update images * fix: reinvite stuff * fix: lint * fix: alignment of save bar * fix: notif sizing * fix: split up access * fix: lint * fix: lint * fix: link --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user