feat: recent activity card on Home and Retention card on Settings

This commit is contained in:
fsylvain committed 2026-10-07 16:01:28 +02:00
1 parent 35db442cd3
commit 43469ef08d
33 files changed
+2748 -204

No files matched your search

+3 -30
View File
@@ -2,8 +2,9 @@
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityAvatar from '@/components/ActivityAvatar.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { initials, type ActivityActor } from '@/lib/activity'
import { type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
@@ -12,39 +13,11 @@ const props = defineProps<{
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',
}"
/>
<ActivityAvatar :actor="actor" />
<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')" />
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { initials, type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
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>
<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',
}"
/>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { t } from '@/i18n'
import ActivityAvatar from '@/components/ActivityAvatar.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import type { ActivityItem } from '@/lib/activity'
import { fullLocalTime, recentWhenLabel } from '@/lib/activityTime'
const SKELETON_ROWS = 5
const SKELETON_SENTENCE_WIDTHS = ['w-17/20', 'w-7/10', 'w-9/10', 'w-3/5', 'w-4/5']
defineProps<{
items: ActivityItem[]
loading: boolean
showSkeleton: boolean
error: boolean
own: boolean
}>()
defineEmits<{
retry: []
}>()
function metaLine(item: ActivityItem, own: boolean) {
const when = recentWhenLabel(item.created_at)
if (own) return when
const name = item.actor.type === 'anonymous' ? t('Not signed in') : item.actor.name
return `${name} · ${when}`
}
</script>
<template>
<UCard
data-testid="recent-activity-card"
:aria-busy="loading && showSkeleton ? 'true' : undefined"
:ui="{
root: 'min-w-0 rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
body: 'flex flex-col px-4 pt-1 pb-1.5 sm:px-4 sm:pt-1 sm:pb-1.5',
}"
>
<template #header>
<h2 class="flex-1 text-base font-semibold text-highlighted">
{{ own ? t('Your recent activity') : t('Recent activity') }}
</h2>
<RouterLink
to="/activity"
class="flex items-center gap-1 font-medium text-highlighted underline underline-offset-3 max-lg:min-h-11 lg:min-h-5"
>
{{ t('View all') }}
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
</RouterLink>
</template>
<template v-if="loading">
<template v-if="showSkeleton">
<div
v-for="n in SKELETON_ROWS"
:key="n"
data-testid="recent-activity-skeleton"
class="flex gap-2.5 py-2.5"
:class="n > 1 ? 'border-t border-muted' : ''"
>
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<div class="flex flex-1 flex-col gap-1.5">
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[n - 1]"
/>
<USkeleton class="h-2.5 w-7/20 rounded-none bg-accented" />
</div>
</div>
</template>
</template>
<UAlert
v-else-if="error"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load recent activity')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div
v-else-if="items.length === 0"
data-testid="recent-activity-empty"
class="flex flex-col items-center gap-1.5 py-6 text-center"
>
<UIcon name="i-lucide-history" class="size-5 text-muted" />
<span class="font-semibold text-highlighted">{{ t('No activity yet') }}</span>
<span class="max-w-60 text-muted">
{{
own
? t('Your actions will appear here.')
: t('Actions on your site will appear here.')
}}
</span>
</div>
<ul v-else class="flex flex-col">
<li
v-for="(item, index) in items"
:key="item.id"
data-testid="recent-activity-row"
class="flex items-start gap-2.5 py-2.5"
:class="index > 0 ? 'border-t border-muted' : ''"
>
<ActivityAvatar :actor="item.actor" />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span
class="line-clamp-2 text-default wrap-anywhere"
data-testid="recent-activity-sentence"
>
<ActivitySentence :item="item" />
</span>
<span
data-testid="recent-activity-meta"
:title="fullLocalTime(item.created_at)"
class="truncate text-xs/5 text-muted"
>
{{ metaLine(item, own) }}
</span>
</div>
<ActivityBadge v-if="item.outcome === 'failed'" tone="fail" :label="t('Failed')" />
<UIcon
v-else-if="item.security"
name="i-lucide-shield-alert"
data-testid="recent-activity-security"
class="size-3.5 shrink-0 text-warning"
/>
</li>
</ul>
</UCard>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ActivityFeedCard from '@/components/ActivityFeedCard.vue'
import { useActivity } from '@/composables/useActivity'
import type { ActivityItem } from '@/lib/activity'
import { useSessionStore } from '@/stores/session'
const SKELETON_DELAY_MS = 150
const RECENT_SIZE = 5
const session = useSessionStore()
const { listRecentActivity } = useActivity()
const items = ref<ActivityItem[]>([])
const loading = ref(true)
const error = ref(false)
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
const own = computed(() => session.session?.role !== 'owner' && session.session?.role !== 'admin')
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function load() {
loading.value = true
error.value = false
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const page = await listRecentActivity(RECENT_SIZE)
items.value = page.items.slice(0, RECENT_SIZE)
} catch {
error.value = true
} finally {
clearSkeletonTimer()
showSkeleton.value = false
loading.value = false
}
}
onMounted(load)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<ActivityFeedCard
:items="items"
:loading="loading"
:show-skeleton="showSkeleton"
:error="error"
:own="own"
@retry="load"
/>
</template>
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { ApiError } from '@/composables/useApi'
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
import {
SAVED_VISIBLE_MS,
helperCopy,
isShorter,
lastCleanupLine,
nextCleanupLine,
retentionOptions,
shorteningDescription,
} from '@/lib/activityRetention'
import { useSessionStore } from '@/stores/session'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const SKELETON_BAR = 'rounded-none bg-accented'
const OUTLINE_BUTTON =
'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
type SaveStatus = 'idle' | 'saving' | 'saved'
const props = defineProps<{
settings: WorkspaceSettings | null
loadState: 'loading' | 'ready' | 'error'
showSkeleton: boolean
}>()
const emit = defineEmits<{
saved: [settings: WorkspaceSettings]
reload: []
}>()
const session = useSessionStore()
const { updateRetention } = useSettings()
const online = useOnline()
const selected = ref(props.settings?.activity_retention_days ?? 0)
const shortening = ref<number | null>(null)
const failedValue = ref<number | null>(null)
const saveStatus = ref<SaveStatus>('idle')
let savedTimer: ReturnType<typeof setTimeout> | null = null
let pendingSaves = 0
const offline = computed(() => !online.value)
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
const options = computed(() => retentionOptions())
watch(savedDays, (days) => {
if (shortening.value === null && pendingSaves === 0) {
selected.value = days
}
})
function clearSavedTimer() {
if (savedTimer !== null) {
clearTimeout(savedTimer)
savedTimer = null
}
}
function finishSaving(succeeded: boolean) {
if (!succeeded) {
saveStatus.value = 'idle'
return
}
saveStatus.value = 'saved'
savedTimer = setTimeout(() => {
saveStatus.value = 'idle'
}, SAVED_VISIBLE_MS)
}
async function save(days: number) {
pendingSaves += 1
clearSavedTimer()
failedValue.value = null
selected.value = days
saveStatus.value = 'saving'
let succeeded = false
try {
const updated = await updateRetention(days)
emit('saved', updated)
selected.value = updated.activity_retention_days
succeeded = true
} catch (error) {
selected.value = savedDays.value
const status = error instanceof ApiError ? error.status : 0
if (status !== HTTP_UNAUTHORIZED) {
failedValue.value = days
}
if (status === HTTP_FORBIDDEN) {
session.refresh()
}
} finally {
pendingSaves -= 1
if (pendingSaves === 0) {
finishSaving(succeeded)
}
}
}
function choose(value: number) {
if (offline.value) {
return
}
shortening.value = null
failedValue.value = null
if (value === savedDays.value) {
selected.value = value
return
}
if (isShorter(value, savedDays.value)) {
selected.value = value
shortening.value = value
return
}
save(value)
}
function apply() {
const days = shortening.value
shortening.value = null
if (days !== null) {
save(days)
}
}
function cancel() {
shortening.value = null
selected.value = savedDays.value
}
function retry() {
if (failedValue.value !== null) {
save(failedValue.value)
}
}
onBeforeUnmount(clearSavedTimer)
</script>
<template>
<UCard
as="section"
aria-labelledby="retention-h"
data-testid="retention-card"
:aria-busy="skeletonVisible ? 'true' : undefined"
:ui="{
root: 'rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
body: 'flex flex-col gap-4 p-4 sm:p-4',
}"
>
<template #header>
<div class="flex min-w-0 flex-1 flex-col">
<h2 id="retention-h" class="text-base font-semibold text-highlighted">
{{ t('Retention') }}
</h2>
<p class="text-xs text-muted">{{ t('How long activity is kept.') }}</p>
</div>
<span
aria-live="polite"
data-testid="retention-save-status"
class="flex h-6 shrink-0 items-center"
>
<span
v-if="saveStatus === 'saving'"
class="inline-flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
{{ t('Saving…') }}
</span>
<span
v-else-if="saveStatus === 'saved'"
class="inline-flex items-center gap-1.5 text-xs text-success"
>
<UIcon name="i-lucide-check" class="size-3.5" />
{{ t('Saved') }}
</span>
</span>
</template>
<template v-if="skeletonVisible">
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-30']" />
<USkeleton :class="[SKELETON_BAR, 'h-8 w-full max-w-65']" />
<USkeleton :class="[SKELETON_BAR, 'h-3 w-3/5']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-9/10']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-7/10']" />
</template>
<UAlert
v-else-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="t(UNREACHABLE_COPY)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('reload')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="loadState === 'ready' && settings">
<UAlert
v-if="failedValue !== null"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save the retention period')"
:description="t('Your previous setting is still in place.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
:class="OUTLINE_BUTTON"
@click="retry"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div class="flex max-w-65 flex-col gap-1.5">
<span id="retention-label" class="font-medium text-highlighted">
{{ t('Keep activity for') }}
</span>
<USelectMenu
:model-value="selected"
:items="options"
value-key="value"
:search-input="false"
:disabled="offline"
aria-labelledby="retention-label"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0 md:h-8',
trailingIcon: 'size-3.5 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose($event as number)"
/>
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
</div>
<UAlert
v-if="shortening !== null"
role="alert"
color="warning"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-triangle-alert"
:title="t('Events older than {0} days will be removed', [shortening])"
:description="shorteningDescription(settings)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
:disabled="offline"
:class="OUTLINE_BUTTON"
@click="apply"
>
{{ t('Apply {0} days', [shortening]) }}
</UButton>
<UButton
color="neutral"
variant="outline"
:disabled="offline"
:class="OUTLINE_BUTTON"
@click="cancel"
>
{{ t('Cancel') }}
</UButton>
</template>
</UAlert>
<UAlert
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-lock"
:title="t('Activity logs are append-only')"
:description="
t(
'Events can\'t be edited or deleted from the app. Only the retention period removes them.',
)
"
/>
<USeparator />
<dl class="flex flex-col gap-1.5">
<div class="flex flex-col md:flex-row md:gap-3">
<dt class="shrink-0 text-muted md:w-30">{{ t('Last cleanup') }}</dt>
<dd data-testid="retention-last" class="text-highlighted">
{{ lastCleanupLine(settings) }}
</dd>
</div>
<div class="flex flex-col md:flex-row md:gap-3">
<dt class="shrink-0 text-muted md:w-30">{{ t('Next cleanup') }}</dt>
<dd data-testid="retention-next" class="text-highlighted">
{{ nextCleanupLine(settings) }}
</dd>
</div>
</dl>
</template>
</UCard>
</template>