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

+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>