feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ActivityCardList from '@/components/ActivityCardList.vue'
|
||||
import ActivityChips from '@/components/ActivityChips.vue'
|
||||
import ActivityDetailsPanel from '@/components/ActivityDetailsPanel.vue'
|
||||
import ActivityEmptyPanel from '@/components/ActivityEmptyPanel.vue'
|
||||
import ActivityFilterBar from '@/components/ActivityFilterBar.vue'
|
||||
import ActivityFiltersSheet from '@/components/ActivityFiltersSheet.vue'
|
||||
import ActivityPager from '@/components/ActivityPager.vue'
|
||||
import ActivitySummaryStrip from '@/components/ActivitySummaryStrip.vue'
|
||||
import ActivityTable from '@/components/ActivityTable.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { useActivityPage } from '@/composables/useActivityPage'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { defaultFilters, type ActivityFilters } from '@/lib/activity'
|
||||
import type { StripKey } from '@/lib/activitySummary'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const activity = useActivityPage()
|
||||
const {
|
||||
filters,
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
listState,
|
||||
summary,
|
||||
summaryState,
|
||||
people,
|
||||
chips,
|
||||
isFullView,
|
||||
isEmpty,
|
||||
exporting,
|
||||
exportDisabled,
|
||||
showListSkeleton,
|
||||
showSummarySkeleton,
|
||||
selectedId,
|
||||
selectedItem,
|
||||
position,
|
||||
canPrevious,
|
||||
canNext,
|
||||
} = activity
|
||||
|
||||
const sheetOpen = ref(false)
|
||||
let started = false
|
||||
let opener: HTMLElement | null = null
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready' && !!session.session?.verified)
|
||||
const title = computed(() =>
|
||||
isFullView.value || session.status !== 'ready' ? t('Activity') : t('My activity'),
|
||||
)
|
||||
const subtitle = computed(() =>
|
||||
isFullView.value
|
||||
? t('Who did what, and when, across your site.')
|
||||
: t("Everything you've done on this site."),
|
||||
)
|
||||
const forbidden = computed(() => listState.value === 'forbidden')
|
||||
const showStrip = computed(
|
||||
() =>
|
||||
isFullView.value &&
|
||||
!forbidden.value &&
|
||||
((summaryState.value !== 'failed' && summary.value !== null) || showSummarySkeleton.value),
|
||||
)
|
||||
const showRows = computed(
|
||||
() =>
|
||||
listState.value === 'ready' ||
|
||||
(listState.value === 'loading' && (showListSkeleton.value || items.value.length > 0)),
|
||||
)
|
||||
const showPager = computed(
|
||||
() => showRows.value && !showListSkeleton.value && !isEmpty.value && items.value.length > 0,
|
||||
)
|
||||
const emptyIsFiltered = computed(() => chips.value.length > 0)
|
||||
|
||||
watch(
|
||||
sessionReady,
|
||||
(ready) => {
|
||||
if (ready && !started) {
|
||||
started = true
|
||||
activity.start()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onFilters(next: ActivityFilters) {
|
||||
activity.applyFilters(next)
|
||||
}
|
||||
|
||||
function onStripCard(key: StripKey) {
|
||||
if (key === 'total') activity.applyFilters(defaultFilters(), true)
|
||||
else if (key === 'failed') activity.applyFilters({ ...filters.value, outcome: 'failed' })
|
||||
else if (key === 'security') activity.applyFilters({ ...filters.value, security: true })
|
||||
}
|
||||
|
||||
function onOpen(item: { id: number }, element: HTMLElement) {
|
||||
opener = element
|
||||
activity.openDetails(item.id)
|
||||
}
|
||||
|
||||
async function onClose() {
|
||||
const id = selectedId.value
|
||||
activity.closeDetails()
|
||||
await nextTick()
|
||||
const row = document.querySelector<HTMLElement>(`[data-activity-id="${id}"]`)
|
||||
;(row ?? opener)?.focus()
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding :mobile-title="title">
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[{ label: title }]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-45 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
|
||||
<p class="text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isFullView && !forbidden"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-download"
|
||||
:disabled="exportDisabled"
|
||||
class="h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
|
||||
@click="activity.runExport"
|
||||
>
|
||||
{{ exporting ? t('Preparing file…') : t('Export CSV') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<ActivityEmptyPanel
|
||||
v-if="forbidden"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You can\'t view this activity')"
|
||||
:description="
|
||||
t(
|
||||
'Your role doesn\'t include this page. Ask an owner or admin if you need access.',
|
||||
)
|
||||
"
|
||||
:action-label="t('Back to home')"
|
||||
@action="goHome"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<ActivitySummaryStrip
|
||||
v-if="showStrip"
|
||||
:summary="summary"
|
||||
:range="filters.range"
|
||||
:loading="showSummarySkeleton"
|
||||
:is-desktop="isDesktop"
|
||||
:outcome="filters.outcome"
|
||||
:security="filters.security"
|
||||
@apply-filter="onStripCard"
|
||||
/>
|
||||
|
||||
<ActivityFilterBar
|
||||
:filters="filters"
|
||||
:people="people"
|
||||
:can-filter-people="isFullView"
|
||||
:is-desktop="isDesktop"
|
||||
:chip-count="chips.length"
|
||||
@update:filters="onFilters"
|
||||
@open-people="activity.ensurePeople"
|
||||
@open-sheet="sheetOpen = true"
|
||||
/>
|
||||
|
||||
<ActivityChips
|
||||
:chips="chips"
|
||||
@remove="activity.removeChip"
|
||||
@clear="activity.clearAll"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="listState === 'error'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t load activity')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
|
||||
@click="activity.retry"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<ActivityEmptyPanel
|
||||
v-else-if="isEmpty && !emptyIsFiltered"
|
||||
icon="i-lucide-history"
|
||||
:title="t('No activity yet')"
|
||||
:description="
|
||||
isFullView
|
||||
? t('Actions on articles, media, users and API keys will appear here.')
|
||||
: t('Your actions will appear here once you start working on the site.')
|
||||
"
|
||||
/>
|
||||
|
||||
<ActivityEmptyPanel
|
||||
v-else-if="isEmpty"
|
||||
icon="i-lucide-search-x"
|
||||
:title="t('No activity matches these filters')"
|
||||
:description="t('Try a different search or a wider date range.')"
|
||||
:action-label="t('Clear filters')"
|
||||
@action="activity.clearAll"
|
||||
/>
|
||||
|
||||
<template v-else-if="showRows">
|
||||
<ActivityTable
|
||||
v-if="isDesktop"
|
||||
:items="items"
|
||||
:loading="showListSkeleton"
|
||||
:selected-id="selectedId"
|
||||
:show-actor="isFullView"
|
||||
@open="onOpen"
|
||||
/>
|
||||
<ActivityCardList
|
||||
v-else
|
||||
:items="items"
|
||||
:loading="showListSkeleton"
|
||||
:selected-id="selectedId"
|
||||
:show-actor="isFullView"
|
||||
@open="onOpen"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ActivityPager
|
||||
v-if="showPager"
|
||||
:page="page"
|
||||
:page-count="activity.pageCount.value"
|
||||
:total="total"
|
||||
:size="size"
|
||||
:from="activity.rangeStart.value"
|
||||
:to="activity.rangeEnd.value"
|
||||
:is-desktop="isDesktop"
|
||||
@update:page="activity.goToPage"
|
||||
@update:size="activity.setSize"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ActivityDetailsPanel
|
||||
:item="selectedItem"
|
||||
:position="position"
|
||||
:total="total"
|
||||
:can-previous="canPrevious"
|
||||
:can-next="canNext"
|
||||
:is-desktop="isDesktop"
|
||||
@previous="activity.previous"
|
||||
@next="activity.next"
|
||||
@close="onClose"
|
||||
/>
|
||||
|
||||
<ActivityFiltersSheet
|
||||
v-if="!isDesktop && !forbidden"
|
||||
v-model:open="sheetOpen"
|
||||
:filters="filters"
|
||||
:people="people"
|
||||
:can-filter-people="isFullView"
|
||||
:chip-count="chips.length"
|
||||
:total="total"
|
||||
@update:filters="onFilters"
|
||||
@open-people="activity.ensurePeople"
|
||||
@clear="activity.clearAll"
|
||||
/>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
Reference in new issue
Block a user