feat(activity): update summary cards to toggles and introduce export menu

This commit is contained in:
Florian Sylvain committed 2026-10-12 05:13:25 +02:00
1 parent 73f4b539fe
commit 03b39e64f2
30 files changed
+2376 -238

No files matched your search

+2 -2
View File
@@ -36,7 +36,7 @@ const emit = defineEmits<{
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
class="size-5 justify-center p-0 text-muted hover:bg-accented hover:text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
@@ -45,7 +45,7 @@ const emit = defineEmits<{
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 hover:bg-elevated focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ExportStage } from '@/composables/useActivityExport'
import { EXPORT_RANGE_KEYS, exportRangeLabel, type ExportRange } from '@/lib/activity'
const props = defineProps<{
stage: ExportStage
canSubmit: boolean
fileName: string
}>()
const emit = defineEmits<{
submit: []
close: []
}>()
const open = defineModel<boolean>('open', { default: false })
const range = defineModel<ExportRange>('range', { default: 'all' })
const from = defineModel<string>('from', { default: '' })
const to = defineModel<string>('to', { default: '' })
const MODAL_CONTENT =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:max-w-md md:top-35 md:translate-y-0 max-md:!inset-x-0 max-md:!top-auto max-md:!bottom-0 max-md:!left-0 max-md:!w-auto max-md:!max-w-none max-md:!translate-x-0 max-md:!translate-y-0 max-md:before:absolute max-md:before:top-2 max-md:before:left-1/2 max-md:before:h-1 max-md:before:w-9 max-md:before:-translate-x-1/2 max-md:before:bg-(--ui-border-accented) max-md:pt-3'
const BUTTON_CLASS = 'h-11 justify-center px-3 text-sm md:h-8'
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default text-highlighted ring-0`
const FIELD_LABEL_CLASS = 'font-medium text-highlighted'
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
const preparing = computed(() => props.stage === 'preparing')
const rangeItems = computed(() =>
EXPORT_RANGE_KEYS.map((key) => ({ value: key, label: exportRangeLabel(key) })),
)
const showCustomFields = computed(() => range.value === 'custom')
function onOpenChange(value: boolean) {
if (!value) emit('close')
}
</script>
<template>
<UModal
:open="open"
:title="t('Export all activity')"
:description="t('Downloads every event as a CSV file. Filters on the page are ignored.')"
:dismissible="!preparing"
:close="preparing ? false : CLOSE_BUTTON"
:ui="{
content: MODAL_CONTENT,
close: 'end-6 top-8 md:top-5',
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
description: 'mt-1 pe-14 text-sm text-muted text-pretty md:pe-10',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-4">
<template v-if="stage === 'configure'">
<UFormField :label="t('Format')" :ui="{ label: FIELD_LABEL_CLASS }">
<UInput
model-value="CSV"
readonly
:aria-label="t('Format')"
class="w-full"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default text-sm text-highlighted ring-0',
}"
/>
</UFormField>
<UFormField
:label="t('Date range')"
:help="t('Default is all time. Filters on the page are ignored.')"
:ui="{ label: FIELD_LABEL_CLASS, help: 'mt-1.5 text-xs text-muted' }"
>
<USelect
v-model="range"
:items="rangeItems"
value-key="value"
icon="i-lucide-calendar"
color="neutral"
variant="outline"
:aria-label="t('Date range')"
class="w-full"
: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',
}"
/>
</UFormField>
<div v-if="showCustomFields" class="flex items-center gap-2">
<UInput
v-model="from"
type="date"
icon="i-lucide-calendar"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
/>
<UInput
v-model="to"
type="date"
icon="i-lucide-calendar"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
/>
</div>
</template>
<div v-else-if="preparing" class="flex flex-col gap-3">
<span class="flex items-center gap-2 font-medium text-highlighted">
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
{{ t('Preparing download…') }}
</span>
<UProgress size="xs" color="primary" />
<span class="text-muted text-pretty">
{{
t(
'The file starts downloading as soon as it is ready. Keep this window open.',
)
}}
</span>
</div>
<div v-else-if="stage === 'started'" class="flex items-start gap-3">
<UIcon
name="i-lucide-circle-check"
class="mt-0.5 size-5 shrink-0 text-success"
/>
<div class="flex flex-col gap-0.5">
<span class="text-md font-semibold text-highlighted">{{
t('Download started')
}}</span>
<span class="text-muted">{{
t(
"{0} is downloading. Check your browser's downloads if nothing appears.",
[fileName],
)
}}</span>
</div>
</div>
<UAlert
v-else-if="stage === 'failed'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:close="false"
:title="t('Couldn\'t prepare the download')"
:description="t('Nothing was downloaded. Try again in a moment.')"
/>
<UAlert
v-else-if="stage === 'denied'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-lock"
:close="false"
:title="t('You can\'t export all activity')"
:description="
t(
'Only owners and admins can export all activity. Ask an owner if you need a copy.',
)
"
/>
<div
v-else-if="stage === 'empty'"
class="flex flex-col items-center gap-2.5 py-3 text-center"
>
<span class="flex size-11 items-center justify-center bg-elevated text-toned">
<UIcon name="i-lucide-file-x" class="size-5" />
</span>
<span class="text-md font-semibold text-highlighted">{{
t('Nothing to export')
}}</span>
<span class="max-w-75 text-muted text-pretty">{{
t('There is no activity in this date range. Choose a wider range.')
}}</span>
</div>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:justify-end">
<template v-if="stage === 'configure' || preparing || stage === 'failed'">
<UButton
color="neutral"
variant="outline"
block
:class="[OUTLINE_CLASS, 'md:w-auto']"
@click="emit('close')"
>
{{ t('Cancel') }}
</UButton>
<UButton
v-if="stage === 'configure'"
icon="i-lucide-download"
block
:class="[BUTTON_CLASS, 'md:w-auto']"
:disabled="!canSubmit"
@click="emit('submit')"
>
{{ t('Export CSV') }}
</UButton>
<UButton
v-else-if="preparing"
block
disabled
:class="[BUTTON_CLASS, 'md:w-auto']"
>
{{ t('Preparing…') }}
</UButton>
<UButton
v-else
icon="i-lucide-rotate-cw"
block
:class="[BUTTON_CLASS, 'md:w-auto']"
@click="emit('submit')"
>
{{ t('Retry') }}
</UButton>
</template>
<UButton
v-else
color="neutral"
variant="outline"
block
:class="[OUTLINE_CLASS, 'md:w-auto']"
@click="emit('close')"
>
{{ t('Close') }}
</UButton>
</div>
</template>
</UModal>
</template>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { NOTHING_TO_EXPORT_COPY, OWNERS_ONLY_COPY } from '@/lib/activity'
import { computed, ref } from 'vue'
const props = defineProps<{
total: number
isDesktop: boolean
disabled: boolean
nothingToExport: boolean
forbidden: boolean
}>()
const emit = defineEmits<{
current: []
all: []
}>()
const open = ref(false)
const BUTTON_CLASS =
'h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 data-[state=open]:bg-elevated md:h-8'
const ITEM_HINT_CLASS = 'text-xs/4.5 text-muted text-pretty'
const currentDisabled = computed(() => props.forbidden || props.nothingToExport)
const currentHint = computed(() => {
if (props.forbidden) return t(OWNERS_ONLY_COPY)
if (props.nothingToExport) return t(NOTHING_TO_EXPORT_COPY)
return props.total === 1
? t('CSV · 1 event matching your filters and sort, across all pages')
: t('CSV · {0} events matching your filters and sort, across all pages', [
formatNumber(props.total),
])
})
const allHint = computed(() =>
props.forbidden
? t(OWNERS_ONLY_COPY)
: t('CSV · every event, ignoring filters. Owners and admins only.'),
)
const trailingIcon = computed(() => (open.value ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'))
const entries = computed(() => [
{
action: 'current' as const,
label: t('Export current view'),
description: currentHint.value,
icon: 'i-lucide-download',
disabled: currentDisabled.value,
},
{
action: 'all' as const,
label: t('Export all activity…'),
description: allHint.value,
icon: props.forbidden ? 'i-lucide-lock' : 'i-lucide-archive',
disabled: props.forbidden,
},
])
const items = computed(() =>
entries.value.map(({ action, label, description, icon, disabled }) => [
{ label, description, icon, disabled, onSelect: () => choose(action) },
]),
)
function choose(action: 'current' | 'all') {
open.value = false
if (action === 'current') emit('current')
else emit('all')
}
</script>
<template>
<UDropdownMenu
v-if="isDesktop"
v-model:open="open"
:items="items"
:content="{ align: 'end', sideOffset: 4 }"
:ui="{
content: 'w-90 rounded-none p-1 shadow-(--rc-shadow-pop)',
group: 'p-0',
separator: 'my-0',
item: 'min-h-14 items-start gap-3 rounded-none px-3 py-2.5 text-sm data-disabled:opacity-100 data-disabled:text-muted',
itemLeadingIcon: 'mt-0.5 size-4 text-highlighted group-data-disabled:text-muted',
itemLabel: 'font-medium',
itemDescription: 'text-xs/4.5 text-muted text-pretty whitespace-normal',
}"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
:trailing-icon="trailingIcon"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
{{ t('Export') }}
</UButton>
</UDropdownMenu>
<template v-else>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
:trailing-icon="trailingIcon"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
@click="open = true"
>
{{ t('Export') }}
</UButton>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Export')"
:description="t('Export')"
: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-0 pb-4 sm:p-0 sm:pb-4',
}"
>
<template #body>
<div role="menu" :aria-label="t('Export')" class="flex flex-col">
<template v-for="(entry, index) in entries" :key="entry.action">
<USeparator v-if="index > 0" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
:icon="entry.icon"
:disabled="entry.disabled"
class="min-h-16 items-start justify-start gap-3 rounded-none px-4 py-3 text-start text-sm text-highlighted disabled:text-muted disabled:opacity-100"
:ui="{ leadingIcon: 'mt-0.5 size-4' }"
@click="choose(entry.action)"
>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="font-medium">{{ entry.label }}</span>
<span :class="[ITEM_HINT_CLASS, 'font-normal']">{{
entry.description
}}</span>
</span>
</UButton>
</template>
</div>
</template>
</USlideover>
</template>
</template>
+73 -11
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
@@ -41,6 +43,14 @@ const VALUE_TONES: Record<StripCard['tone'], string> = {
warning: 'text-warning',
calm: 'text-highlighted',
}
const PRESSED_TEXT = 'text-(--rc-on-sand)'
const PRESSED_BAR = 'bg-(--rc-on-sand)'
const hovered = ref<StripKey | null>(null)
function isToggle(key: StripKey) {
return key === 'failed' || key === 'security'
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
@@ -49,21 +59,63 @@ function pressed(key: StripKey) {
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
const isPressed = pressed(card.key)
const fill = isPressed
? 'bg-(--rc-sand)'
: card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
const edge = isPressed
? 'border-primary ring-1 ring-primary ring-inset'
: 'border-default ring-0'
: isToggle(card.key)
? 'border-default ring-0 hover:border-accented hover:bg-elevated'
: '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,
]
}
function labelClass(card: StripCard) {
return pressed(card.key) ? PRESSED_TEXT : 'text-toned'
}
function valueClass(card: StripCard) {
return pressed(card.key) ? PRESSED_TEXT : VALUE_TONES[card.tone]
}
function barClass(card: StripCard) {
return pressed(card.key) ? PRESSED_BAR : BAR_TONES[card.tone]
}
function sublineClass(card: StripCard) {
if (pressed(card.key)) return PRESSED_TEXT
return card.tone === 'calm' ? 'text-success' : 'text-muted'
}
function hint(card: StripCard) {
if (!isToggle(card.key)) return null
if (pressed(card.key)) {
return hovered.value === card.key
? { text: t('Click to clear this filter'), icon: 'i-lucide-x' }
: { text: t('Filtering. Click to clear'), icon: 'i-lucide-filter' }
}
return hovered.value === card.key
? { text: t('Click to filter the list'), icon: 'i-lucide-filter' }
: null
}
function sublineText(card: StripCard) {
return hint(card)?.text ?? card.subline
}
function sublineIcon(card: StripCard) {
const shown = hint(card)
return shown ? shown.icon : card.icon
}
</script>
<template>
@@ -102,11 +154,15 @@ function rootClass(card: StripCard) {
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@mouseenter="hovered = card.key"
@mouseleave="hovered = null"
@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 class="truncate font-medium" :class="labelClass(card)">{{
card.label
}}</span>
<span
aria-hidden="true"
data-testid="activity-bars"
@@ -117,22 +173,28 @@ function rootClass(card: StripCard) {
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:class="barClass(card)"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
:class="valueClass(card)"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
:class="sublineClass(card)"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
<UIcon
v-if="sublineIcon(card)"
:name="sublineIcon(card)!"
class="size-3 shrink-0"
/>
<span class="truncate" data-testid="activity-strip-subline">{{
sublineText(card)
}}</span>
</span>
</span>
</UCard>