feat: activity screen in the SPA, route /activity

This commit is contained in:
fsylvain committed 2026-10-07 01:48:21 +02:00
1 parent e37a46a027
commit 35db442cd3
47 files changed
+7582 -11

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { initials, type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
const displayName = computed(() =>
props.actor.type === 'anonymous' ? t('Not signed in') : props.actor.name,
)
const iconName = computed(() => {
switch (props.actor.type) {
case 'api_key':
return 'i-lucide-key-round'
case 'system':
return 'i-lucide-cog'
default:
return 'i-lucide-user-round-x'
}
})
</script>
<template>
<span class="flex min-w-0 items-center gap-2" data-testid="activity-actor">
<UAvatar
v-if="actor.type === 'user'"
aria-hidden="true"
:text="initials(actor.name)"
:ui="{
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
}"
/>
<UAvatar
v-else
aria-hidden="true"
:icon="iconName"
:ui="{
root: 'size-6 shrink-0 border border-accented bg-elevated',
icon: 'size-3.5 text-toned',
}"
/>
<span class="min-w-0 truncate font-medium text-highlighted">{{ displayName }}</span>
<ActivityBadge v-if="actor.type === 'api_key'" tone="neutral" :label="t('API key')" />
<ActivityBadge v-else-if="actor.type === 'system'" tone="neutral" :label="t('System')" />
</span>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
export type ActivityBadgeTone = 'ok' | 'fail' | 'security' | 'deleted' | 'neutral'
const TONES: Record<ActivityBadgeTone, { class: string; icon?: string }> = {
ok: { class: 'border border-accented bg-transparent text-toned', icon: 'i-lucide-check' },
fail: { class: 'bg-(--rc-error-soft) text-error', icon: 'i-lucide-circle-x' },
security: { class: 'bg-(--rc-warning-soft) text-warning', icon: 'i-lucide-shield-alert' },
deleted: {
class: 'border border-accented bg-transparent text-toned',
icon: 'i-lucide-trash-2',
},
neutral: { class: 'border border-accented bg-transparent text-toned' },
}
const props = defineProps<{
tone: ActivityBadgeTone
label: string
}>()
const config = computed(() => TONES[props.tone])
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:label="label"
:icon="config.icon"
:class="config.class"
:ui="{ base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap', leadingIcon: 'size-3' }"
/>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import { ACTIVITY_SKELETON_CARDS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const SKELETON_SENTENCE_WIDTHS = ['w-9/10', 'w-3/4', 'w-17/20']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function cardClass(item: ActivityItem) {
return rowSurface(props.selectedId === item.id, item.security)
}
</script>
<template>
<div
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_CARDS"
:key="n"
class="flex flex-col gap-2.5 border-t border-default px-4 py-3.5"
data-testid="activity-skeleton-card"
>
<div class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
<span class="flex-1" />
<USkeleton class="h-2.5 w-10 rounded-none bg-accented" />
</div>
<USkeleton
class="h-3.5 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[(n - 1) % 3]"
/>
<USkeleton class="h-5 w-17.5 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div
data-testid="activity-day"
class="flex h-8 shrink-0 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
<div
v-for="item in group.items"
:key="item.id"
tabindex="0"
role="button"
data-testid="activity-card"
:data-activity-id="item.id"
:aria-pressed="selectedId === item.id"
:class="cardClass(item)"
class="flex min-h-11 cursor-pointer flex-col gap-2 border-t border-default px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<div class="flex min-w-0 items-center gap-2">
<ActivityActor v-if="showActor" :actor="item.actor" class="flex-1" />
<span v-else class="flex-1" />
<span
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="text-xs/5 whitespace-nowrap text-muted"
>{{ whenLabel(item.created_at) }}</span
>
</div>
<div class="flex items-start gap-2">
<UIcon
:name="categoryIcon(item.category)"
class="mt-0.5 size-4 shrink-0 text-muted"
/>
<span
class="line-clamp-3 min-w-0 flex-1 text-md wrap-anywhere text-default"
>
<ActivitySentence :item="item" />
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
<ActivityBadge
v-if="item.security"
tone="security"
:label="t('Security')"
/>
<ActivityBadge
v-if="item.target && !item.target.exists"
tone="deleted"
:label="t('Target deleted')"
/>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { ACTIVITY_CATEGORIES, categoryLabel, type ActivityCategory } from '@/lib/activity'
defineProps<{
category: ActivityCategory | ''
}>()
const emit = defineEmits<{
select: [category: ActivityCategory]
}>()
const items = computed(() =>
ACTIVITY_CATEGORIES.map((category) => ({
label: categoryLabel(category),
value: category,
})),
)
</script>
<template>
<USelectMenu
:model-value="category || undefined"
:items="items"
value-key="value"
icon="i-lucide-layout-list"
:placeholder="t('All categories')"
:search-input="false"
:aria-label="t('Category')"
color="neutral"
variant="outline"
class="w-full md:w-44"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:model-value="emit('select', $event as ActivityCategory)"
/>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { ActivityChip, ChipKey } from '@/lib/activity'
defineProps<{
chips: ActivityChip[]
}>()
const emit = defineEmits<{
remove: [key: ChipKey]
clear: []
}>()
</script>
<template>
<div
v-if="chips.length > 0"
data-testid="activity-chips"
class="flex flex-wrap items-center gap-2"
>
<UBadge
v-for="chip in chips"
:key="chip.key"
color="neutral"
variant="subtle"
data-testid="activity-chip"
:ui="{
base: 'h-8 gap-1 border border-default bg-elevated py-0 ps-2 pe-1 text-xs font-medium text-highlighted ring-0 md:h-6',
}"
>
{{ chip.label }}
<template #trailing>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
</template>
</UBadge>
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
</div>
</template>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { CalendarDate, parseDate } from '@internationalized/date'
import { RANGE_KEYS, rangeLabel, type RangeKey } from '@/lib/activity'
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
interface RangeChange {
range: RangeKey
from: string
to: string
}
interface CalendarRange {
start?: CalendarDate
end?: CalendarDate
}
const props = defineProps<{
range: RangeKey
from: string
to: string
block?: boolean
}>()
const emit = defineEmits<{
change: [value: RangeChange]
}>()
const popoverOpen = ref(false)
const customPending = ref(false)
const draftFrom = ref(props.from)
const draftTo = ref(props.to)
const presets = RANGE_KEYS.filter((key) => key !== 'custom')
const customSelected = computed(
() => props.range === 'custom' || popoverOpen.value || customPending.value,
)
const showFields = computed(() => props.block && customSelected.value)
function parse(value: string) {
try {
return parseDate(value)
} catch {
return undefined
}
}
const calendarValue = computed<CalendarRange>(() => ({
start: parse(props.from),
end: parse(props.to),
}))
watch(
() => [props.from, props.to],
([from, to]) => {
draftFrom.value = from ?? ''
draftTo.value = to ?? ''
},
)
function choose(range: RangeKey) {
customPending.value = false
emit('change', { range, from: '', to: '' })
}
function chooseCustom() {
if (props.block) customPending.value = true
}
function applyCustom(from: string, to: string) {
customPending.value = false
emit(
'change',
from <= to ? { range: 'custom', from, to } : { range: 'custom', from: to, to: from },
)
}
function onCalendar(value: CalendarRange | null) {
if (!value?.start || !value.end) return
popoverOpen.value = false
applyCustom(value.start.toString(), value.end.toString())
}
function onField(side: 'from' | 'to', value: string | number | null | undefined) {
const next = typeof value === 'string' ? value : ''
if (side === 'from') draftFrom.value = next
else draftTo.value = next
if (draftFrom.value && draftTo.value) applyCustom(draftFrom.value, draftTo.value)
}
</script>
<template>
<div class="flex flex-col gap-2" :class="block ? 'w-full' : ''">
<div role="group" :aria-label="t('Dates')" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="key in presets"
:key="key"
color="neutral"
:variant="range === key && !customSelected ? 'soft' : 'outline'"
:aria-pressed="range === key && !customSelected"
:class="segmentClass(range === key && !customSelected, block)"
@click="choose(key)"
>
{{ rangeLabel(key) }}
</UButton>
<UButton
v-if="block"
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected, block)"
:ui="{ leadingIcon: 'size-3.5' }"
@click="chooseCustom"
>
{{ rangeLabel('custom') }}
</UButton>
<UPopover v-else v-model:open="popoverOpen" :content="{ align: 'end' }">
<UButton
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected)"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ rangeLabel('custom') }}
</UButton>
<template #content>
<UCalendar
range
:model-value="calendarValue"
:number-of-months="1"
class="p-2"
@update:model-value="onCalendar($event as CalendarRange | null)"
/>
</template>
</UPopover>
</div>
<div v-if="showFields" class="flex items-center gap-2">
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftFrom"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('from', $event)"
/>
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftTo"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('to', $event)"
/>
</div>
</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDiff from '@/components/ActivityDiff.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import {
categoryIcon,
categoryLabel,
outcomeBadge,
sourceLabel,
targetLink,
targetTypeLabel,
type ActivityItem,
} from '@/lib/activity'
import { fullLocalTime, fullUtcTime } from '@/lib/activityTime'
const props = defineProps<{
item: ActivityItem | null
position: number
total: number
canPrevious: boolean
canNext: boolean
isDesktop: boolean
}>()
const emit = defineEmits<{
previous: []
next: []
close: []
}>()
const retained = ref<ActivityItem | null>(props.item)
watch(
() => props.item,
(value) => {
if (value) retained.value = value
},
)
const current = computed(() => props.item ?? retained.value)
const link = computed(() => targetLink(current.value?.target ?? null))
const side = computed(() => (props.isDesktop ? 'right' : 'bottom'))
const positionOf = computed(() =>
t('{0} of {1}', [formatNumber(props.position), formatNumber(props.total)]),
)
const contentClass = computed(() =>
props.isDesktop
? 'w-full max-w-120'
: 'h-[88%] max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
icon: 'i-lucide-x',
class: 'text-muted',
}))
function onOpenChange(isOpen: boolean) {
if (!isOpen) emit('close')
}
</script>
<template>
<USlideover
:open="item !== null"
:side="side"
:title="t('Activity details')"
:description="t('Activity details')"
:close="closeButton"
:ui="{
content: contentClass,
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4 md:px-5',
wrapper: 'min-w-0 flex-1 pe-10 md:pe-8',
title: 'flex items-baseline justify-between gap-2 text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0 md:top-3.5 md:end-5 md:size-7',
body: 'flex-1 overflow-y-auto p-4 sm:p-4 md:p-5',
footer: 'flex gap-2 border-t border-default px-4 py-3 sm:px-4 md:px-5',
}"
@update:open="onOpenChange"
>
<template #title>
<span>{{ t('Activity details') }}</span>
<span v-if="!isDesktop" class="text-xs font-normal text-muted">{{ positionOf }}</span>
</template>
<template #body>
<div v-if="current" data-testid="activity-details" class="flex flex-col gap-5">
<div class="flex flex-col gap-2.5">
<p
data-testid="activity-details-sentence"
class="text-lead font-semibold wrap-anywhere text-highlighted"
>
<ActivitySentence :item="current" />
</p>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(current.outcome)" />
<ActivityBadge
v-if="current.security"
tone="security"
:label="t('Security')"
/>
<span class="inline-flex items-center gap-1 text-xs text-muted">
<UIcon :name="categoryIcon(current.category)" class="size-3.5" />
{{ categoryLabel(current.category) }}
</span>
</div>
</div>
<UAlert
v-if="current.outcome === 'failed' && current.failure_reason"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Why it failed')"
:description="current.failure_reason"
/>
<dl class="grid grid-cols-[96px_minmax(0,1fr)] items-start gap-x-3 gap-y-2.5">
<dt class="text-muted">{{ t('When') }}</dt>
<dd class="m-0 flex flex-col gap-0.5">
<span class="text-highlighted">{{
t('{0} (your time)', [fullLocalTime(current.created_at)])
}}</span>
<span class="text-xs/5 text-muted">{{
t('{0} UTC', [fullUtcTime(current.created_at)])
}}</span>
</dd>
<dt class="text-muted">{{ t('Actor') }}</dt>
<dd class="m-0 min-w-0"><ActivityActor :actor="current.actor" /></dd>
<dt class="text-muted">{{ t('Source') }}</dt>
<dd class="m-0 text-highlighted">{{ sourceLabel(current.source) }}</dd>
<dt class="text-muted">{{ t('IP address') }}</dt>
<dd v-if="current.ip" class="m-0 font-mono text-xs/5 text-highlighted">
{{ current.ip }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
<dt class="text-muted">{{ t('Browser') }}</dt>
<dd v-if="current.user_agent" class="m-0 text-highlighted">
{{ current.user_agent }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
</dl>
<section
v-if="current.target"
class="flex flex-col gap-2"
data-testid="activity-target-section"
>
<h3 class="text-base font-semibold text-highlighted">{{ t('Target') }}</h3>
<div class="flex flex-col gap-1.5 border border-default px-3 py-2.5">
<div class="flex min-w-0 items-center gap-2">
<template v-if="!current.target.exists">
<span
class="min-w-0 flex-1 wrap-anywhere text-muted line-through"
>{{ current.target.name }}</span
>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</template>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 flex-1 items-center gap-1 wrap-anywhere text-highlighted underline underline-offset-3"
>
{{ current.target.name }}
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</RouterLink>
<span v-else class="min-w-0 flex-1 wrap-anywhere text-highlighted">{{
current.target.name
}}</span>
</div>
<span class="text-xs/5 text-muted">{{
`${targetTypeLabel(current.target.type)} · ${current.target.id}`
}}</span>
<span v-if="!current.target.exists" class="text-xs/5 text-toned">{{
t('This item was deleted. Its name is kept for reference.')
}}</span>
</div>
</section>
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">{{ t('Changes') }}</h3>
<ActivityDiff :changes="current.changes" :is-desktop="isDesktop" />
</section>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-up"
:disabled="!canPrevious"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('previous')"
>
{{ t('Previous event') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-down"
:disabled="!canNext"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('next')"
>
{{ t('Next event') }}
</UButton>
<span v-if="isDesktop" class="ml-auto self-center text-xs text-muted">{{
t('Event {0} of {1}', [formatNumber(position), formatNumber(total)])
}}</span>
</template>
</USlideover>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { bodyDeltaLabel, changeValue, fieldLabel, type ActivityChange } from '@/lib/activity'
const props = defineProps<{
changes: ActivityChange[]
isDesktop: boolean
}>()
const expanded = ref<Record<number, boolean>>({})
const overflowing = ref<Record<number, boolean>>({})
const valueBoxes = new Map<number, HTMLElement[]>()
function register(index: number, element: unknown) {
if (!(element instanceof HTMLElement)) return
const boxes = valueBoxes.get(index) ?? []
if (!boxes.includes(element)) valueBoxes.set(index, [...boxes, element])
}
function measure() {
const next: Record<number, boolean> = { ...overflowing.value }
for (const [index, boxes] of valueBoxes) {
if (expanded.value[index]) continue
next[index] = boxes.some((box) => box.scrollHeight > box.clientHeight)
}
overflowing.value = next
}
function toggle(index: number) {
expanded.value = { ...expanded.value, [index]: !expanded.value[index] }
}
watch(
() => props.changes,
() => {
expanded.value = {}
overflowing.value = {}
valueBoxes.clear()
nextTick(measure)
},
)
onMounted(() => {
nextTick(measure)
window.addEventListener('resize', measure)
})
onBeforeUnmount(() => window.removeEventListener('resize', measure))
</script>
<template>
<div data-testid="activity-diff" class="flex flex-col border border-default">
<div
v-for="(change, index) in changes"
:key="index"
data-testid="activity-change"
class="flex flex-col gap-2 border-t border-muted p-3 first:border-t-0"
>
<span class="font-medium text-highlighted">{{ fieldLabel(change.field) }}</span>
<span
v-if="change.kind === 'body'"
data-testid="activity-body-change"
class="flex items-center gap-1.5 text-toned"
>
<UIcon name="i-lucide-file-pen-line" class="size-3.5 shrink-0" />
{{ bodyDeltaLabel(change.delta) }}
</span>
<template v-else>
<div
class="grid items-start gap-1.5 md:grid-cols-[minmax(0,1fr)_16px_minmax(0,1fr)] md:gap-2"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('Before') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-before"
class="border border-default bg-elevated px-2.5 py-2 wrap-anywhere text-toned"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.before) }}</span
>
</div>
<UIcon
:name="isDesktop ? 'i-lucide-arrow-right' : 'i-lucide-arrow-down'"
class="size-3.5 shrink-0 justify-self-center text-muted md:mt-7.5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('After') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-after"
class="border border-accented bg-default px-2.5 py-2 wrap-anywhere text-highlighted"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.after) }}</span
>
</div>
</div>
<UButton
v-if="overflowing[index]"
color="neutral"
variant="link"
class="min-h-8 self-start px-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-4"
@click="toggle(index)"
>
{{ expanded[index] ? t('Show less') : t('Show more') }}
</UButton>
</template>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
description: string
actionLabel?: string
}>()
const emit = defineEmits<{ action: [] }>()
</script>
<template>
<UEmpty
variant="naked"
:icon="icon"
:title="title"
:description="description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14 sm:px-6 sm:py-14 lg:px-6 lg:py-14',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md/5 font-semibold',
description: 'max-w-85 text-sm/5 text-muted text-pretty',
}"
>
<template v-if="actionLabel" #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 md:h-8"
@click="emit('action')"
>
{{ actionLabel }}
</UButton>
</template>
</UEmpty>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const SEARCH_DEBOUNCE_MS = 300
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
isDesktop: boolean
chipCount: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
'open-sheet': []
}>()
const searchText = ref(props.filters.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const searchIconClass = computed(
() => `size-3.5 ${searchText.value ? 'text-highlighted' : 'text-muted'}`,
)
const outcomeChoices = computed(() => outcomeOptions())
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
function applySearch() {
clearDebounce()
const trimmed = searchText.value.trim()
if (trimmed !== props.filters.q) emit('update:filters', { ...props.filters, q: trimmed })
}
watch(
() => props.filters.q,
(value) => {
if (value !== searchText.value.trim()) {
clearDebounce()
searchText.value = value
}
},
)
watch(searchText, () => {
clearDebounce()
debounceTimer = setTimeout(applySearch, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
searchText.value = ''
applySearch()
}
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
onBeforeUnmount(clearDebounce)
</script>
<template>
<div
v-if="isDesktop"
data-testid="activity-filter-bar"
class="flex flex-wrap items-center gap-2"
>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="w-60"
:ui="{ base: 'h-8 ps-8 text-sm', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<ActivityPersonSelect
v-if="canFilterPeople"
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
<ActivitySegmented
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
<div class="min-w-0 flex-1" />
<ActivityDatePresets
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
<div v-else data-testid="activity-filter-bar" class="flex gap-2">
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 ps-8 text-lg', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-sliders-horizontal"
class="h-11 shrink-0 border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('open-sheet')"
>
{{ t('Filters') }}
<span
v-if="chipCount > 0"
data-testid="activity-filter-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-primary px-1.25 font-mono text-2xs font-medium text-(--ui-bg)"
>{{ chipCount }}</span
>
</UButton>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
chipCount: number
total: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
clear: []
}>()
const open = defineModel<boolean>('open', { default: false })
const outcomeChoices = computed(() => outcomeOptions())
const showLabel = computed(() =>
props.total === 1 ? t('Show 1 result') : t('Show {0} results', [formatNumber(props.total)]),
)
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Filters')"
:description="t('Filters')"
:ui="{
content:
'max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
title: 'text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0',
body: 'p-4 sm:p-4',
footer: 'gap-2 border-t border-default px-4 pt-3 pb-4 sm:px-4',
}"
>
<template #body>
<div data-testid="activity-filters-sheet" class="flex flex-col gap-4">
<div v-if="canFilterPeople" class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Person') }}</span>
<ActivityPersonSelect
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Category') }}</span>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Outcome') }}</span>
<ActivitySegmented
block
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Dates') }}</span>
<ActivityDatePresets
block
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
:disabled="chipCount === 0"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
<UButton class="h-11 flex-1 justify-center px-3 text-sm" @click="open = false">
{{ showLabel }}
</UButton>
</template>
</USlideover>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { PAGE_SIZES } from '@/lib/activity'
const props = defineProps<{
page: number
pageCount: number
total: number
size: number
from: number
to: number
isDesktop: boolean
}>()
const emit = defineEmits<{
'update:page': [page: number]
'update:size': [size: number]
}>()
const sizeItems = PAGE_SIZES.map((value) => ({ label: String(value), value }))
const range = computed(() => ({
from: formatNumber(props.from),
to: formatNumber(props.to),
total: formatNumber(props.total),
}))
const PAGER_BUTTON =
'size-11 justify-center border border-accented bg-default p-0 text-highlighted ring-0 md:size-8'
</script>
<template>
<div v-if="isDesktop" data-testid="activity-pager" class="flex shrink-0 items-center gap-4">
<span class="text-muted">{{
t('Showing {0}–{1} of {2}', [range.from, range.to, range.total])
}}</span>
<div class="flex-1" />
<div class="flex items-center gap-2 text-muted">
<span>{{ t('Rows per page') }}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-20"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
</div>
<UPagination
:page="page"
:total="total"
:items-per-page="size"
:sibling-count="1"
show-edges
color="neutral"
variant="outline"
active-color="primary"
active-variant="solid"
:ui="{
item: 'size-8 justify-center border border-accented ring-0',
prev: 'size-8 justify-center border border-accented ring-0',
next: 'size-8 justify-center border border-accented ring-0',
}"
@update:page="emit('update:page', $event)"
/>
</div>
<div v-else data-testid="activity-pager" class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="page <= 1"
:aria-label="t('Previous page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page - 1)"
/>
<div class="flex flex-1 flex-col items-center text-xs text-muted">
<span class="font-medium text-highlighted">{{
t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)])
}}</span>
<span>{{ t('{0}–{1} of {2}', [range.from, range.to, range.total]) }}</span>
</div>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-24 shrink-0"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-right"
:disabled="page >= pageCount"
:aria-label="t('Next page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page + 1)"
/>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { PersonOptions } from '@/composables/useActivity'
const props = defineProps<{
actor: string
people: PersonOptions | null
}>()
const emit = defineEmits<{
select: [actor: string, label: string]
open: []
}>()
const groups = computed(() => {
const users = (props.people?.users ?? []).map((user) => ({
label: user.name,
value: `user:${user.id}`,
}))
const keys = (props.people?.keys ?? []).map((key) => ({
label: key.name,
value: `key:${key.id}`,
}))
return [
[{ type: 'label' as const, label: t('Users') }, ...users],
[{ type: 'label' as const, label: t('API keys') }, ...keys],
[{ label: t('System'), value: 'system' }],
]
})
function labelFor(value: string) {
if (value === 'system') return t('System')
const options = value.startsWith('user:') ? props.people?.users : props.people?.keys
const id = Number(value.slice(value.indexOf(':') + 1))
return options?.find((option) => option.id === id)?.name ?? value
}
function onSelect(value: string | undefined) {
if (value) emit('select', value, labelFor(value))
}
</script>
<template>
<USelectMenu
:model-value="actor || undefined"
:items="groups"
value-key="value"
icon="i-lucide-user-round"
:placeholder="t('All people')"
:search-input="false"
:aria-label="t('Person')"
color="neutral"
variant="outline"
class="w-full md:w-42"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:open="(isOpen: boolean) => isOpen && emit('open')"
@update:model-value="onSelect($event as string | undefined)"
/>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts" generic="V extends string">
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
defineProps<{
options: { value: V; label: string }[]
modelValue: V
label: string
block?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: V]
}>()
</script>
<template>
<div role="group" :aria-label="label" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="option in options"
:key="option.value"
color="neutral"
:variant="option.value === modelValue ? 'soft' : 'outline'"
:aria-pressed="option.value === modelValue"
:class="segmentClass(option.value === modelValue, block)"
@click="emit('update:modelValue', option.value)"
>
{{ option.label }}
</UButton>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityItem } from '@/lib/activity'
import { composeSentence } from '@/lib/activitySentence'
const props = defineProps<{
item: ActivityItem
}>()
const segments = computed(() => composeSentence(props.item))
</script>
<template>
<span data-testid="activity-sentence"
><template v-for="(segment, index) in segments" :key="index"
><span v-if="segment.strong" class="font-semibold text-highlighted">{{
segment.text
}}</span
><template v-else>{{ segment.text }}</template></template
></span
>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
STRIP_KEYS,
stripCards,
type StripCard,
type StripKey,
} from '@/lib/activitySummary'
const DESKTOP_BAR_HEIGHT = 20
const MOBILE_BAR_HEIGHT = 16
const props = defineProps<{
summary: ActivitySummary | null
range: ActivityFilters['range']
loading: boolean
isDesktop: boolean
outcome: ActivityFilters['outcome']
security: boolean
}>()
const emit = defineEmits<{
'apply-filter': [key: StripKey]
}>()
const cards = computed(() => (props.summary ? stripCards(props.summary, props.range) : []))
const barHeight = computed(() => (props.isDesktop ? DESKTOP_BAR_HEIGHT : MOBILE_BAR_HEIGHT))
const CARD_PADDING = 'px-3 py-2.5 md:px-3.5 md:py-3'
const BAR_TONES: Record<StripCard['tone'], string> = {
neutral: 'bg-(--ui-text-muted)',
error: 'bg-error',
warning: 'bg-warning',
calm: 'bg-(--ui-border-accented)',
}
const VALUE_TONES: Record<StripCard['tone'], string> = {
neutral: 'text-highlighted',
error: 'text-error',
warning: 'text-warning',
calm: 'text-highlighted',
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
if (key === 'security') return props.security
return false
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
? 'border-primary ring-1 ring-primary ring-inset'
: 'border-default ring-0'
return [
'w-full cursor-pointer border text-left max-md:w-auto max-md:shrink-0 max-md:basis-39 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
fill,
edge,
]
}
</script>
<template>
<div
data-testid="activity-strip"
class="grid grid-cols-4 gap-3 max-md:-mx-4 max-md:flex max-md:gap-2 max-md:overflow-hidden max-md:px-4"
>
<template v-if="loading">
<UCard
v-for="key in STRIP_KEYS"
:key="key"
data-testid="activity-strip-skeleton"
:ui="{
root: 'border border-default bg-default ring-0 max-md:w-39 max-md:flex-none',
body: 'flex flex-col gap-2 p-3.5 sm:p-3.5 max-md:px-3 max-md:py-2.5 max-md:sm:px-3 max-md:sm:py-2.5',
}"
>
<USkeleton class="h-3 w-11/20 rounded-none bg-accented" />
<USkeleton class="h-5.5 w-2/5 rounded-none bg-accented" />
<USkeleton class="h-2.5 w-7/10 rounded-none bg-accented" />
</UCard>
</template>
<template v-else>
<UCard
v-for="card in cards"
:key="card.key"
as="button"
type="button"
data-testid="activity-strip-card"
:data-key="card.key"
:aria-pressed="
card.key === 'failed' || card.key === 'security' ? pressed(card.key) : undefined
"
:class="rootClass(card)"
:ui="{
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@click="emit('apply-filter', card.key)"
>
<span class="flex flex-col gap-0.5" :class="CARD_PADDING">
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-toned">{{ card.label }}</span>
<span
aria-hidden="true"
data-testid="activity-bars"
class="flex shrink-0 items-end gap-0.5"
:style="{ height: `${barHeight}px` }"
>
<span
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
</span>
</span>
</UCard>
</template>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import ActivityTargetCell from '@/components/ActivityTargetCell.vue'
import { ACTIVITY_SKELETON_ROWS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const FULL_GRID = 'grid-cols-[96px_210px_minmax(0,1fr)_230px_120px]'
const OWN_GRID = 'grid-cols-[96px_minmax(0,1fr)_230px_120px]'
const SKELETON_ACTION_WIDTHS = ['w-7/10', 'w-11/20', 'w-41/50']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const grid = computed(() => `grid ${props.showActor ? FULL_GRID : OWN_GRID}`)
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function rowClass(item: ActivityItem) {
return [grid.value, ...rowSurface(props.selectedId === item.id, item.security)]
}
</script>
<template>
<div
role="table"
:aria-label="t('Activity')"
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<div
role="row"
:class="grid"
class="h-9 shrink-0 items-center bg-elevated pr-4 font-medium text-muted"
>
<span role="columnheader" class="flex items-center gap-1 pl-4 text-highlighted">
{{ t('When') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
<span v-if="showActor" role="columnheader">{{ t('Actor') }}</span>
<span role="columnheader">{{ t('Action') }}</span>
<span role="columnheader">{{ t('Target') }}</span>
<span role="columnheader">{{ t('Outcome') }}</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_ROWS"
:key="n"
role="row"
:class="grid"
class="h-13 items-center border-t border-default pr-4"
data-testid="activity-skeleton-row"
>
<span class="pl-4"><USkeleton class="h-3 w-14 rounded-none bg-accented" /></span>
<span v-if="showActor" class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
</span>
<span>
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_ACTION_WIDTHS[(n - 1) % 3]"
/>
</span>
<span><USkeleton class="h-3 w-3/5 rounded-none bg-accented" /></span>
<span><USkeleton class="h-5 w-18 rounded-none bg-accented" /></span>
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div role="row" data-testid="activity-day">
<div
role="cell"
class="flex h-8 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
</div>
<div
v-for="item in group.items"
:key="item.id"
role="row"
tabindex="0"
data-testid="activity-row"
:data-activity-id="item.id"
:aria-selected="selectedId === item.id"
:class="rowClass(item)"
class="min-h-13 cursor-pointer items-center border-t border-default pr-4 hover:bg-elevated focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<span
role="cell"
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="pl-4 whitespace-nowrap text-toned"
>
{{ whenLabel(item.created_at) }}
</span>
<span v-if="showActor" role="cell" class="min-w-0 pr-3">
<ActivityActor :actor="item.actor" />
</span>
<span role="cell" class="flex min-w-0 items-center gap-2.5 pr-4">
<UIcon
:name="categoryIcon(item.category)"
class="size-4 shrink-0 text-muted"
/>
<span class="truncate text-default">
<ActivitySentence :item="item" />
</span>
<UIcon
v-if="item.security"
name="i-lucide-shield-alert"
role="img"
:aria-label="t('Security')"
class="size-3.5 shrink-0 text-warning"
/>
</span>
<span role="cell" class="min-w-0">
<ActivityTargetCell :target="item.target" />
</span>
<span role="cell" class="flex">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
</span>
</div>
</template>
</template>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { targetLink, targetTypeLabel, type ActivityTarget } from '@/lib/activity'
const props = defineProps<{
target: ActivityTarget | null
}>()
const link = computed(() => targetLink(props.target))
</script>
<template>
<span v-if="!target" class="text-muted">—</span>
<span v-else class="flex min-w-0 flex-col pr-3" data-testid="activity-target">
<span v-if="!target.exists" class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 flex-1 truncate text-muted line-through">{{ target.name }}</span>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</span>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 items-center gap-1 text-highlighted underline underline-offset-3"
@click.stop
>
<span class="truncate">{{ target.name }}</span>
<UIcon name="i-lucide-arrow-up-right" class="size-3 shrink-0 text-muted" />
</RouterLink>
<span v-else class="truncate text-highlighted">{{ target.name }}</span>
<span class="text-xs/5 text-muted">{{ targetTypeLabel(target.type) }}</span>
</span>
</template>
+7
View File
@@ -92,6 +92,13 @@ const navItems = computed(() =>
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Activity')
},
to: '/activity',
icon: 'i-lucide-history',
},
{
get label() {
return t('Integration')
@@ -58,8 +58,8 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the six navigation destinations', () => {
it('lists Home, Articles, Media, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the seven navigation destinations', () => {
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
@@ -70,6 +70,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
@@ -85,7 +86,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same six destinations in the mobile drawer and Settings current', async () => {
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -100,11 +101,12 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
expect(links[5].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[5].getAttribute('aria-current')).toBe('page')
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[6].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
@@ -263,11 +265,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the six navigation items current on /profile', async () => {
it('marks none of the seven navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6)
expect(links).toHaveLength(7)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})