feat: recent activity card on Home and Retention card on Settings
This commit is contained in:
1 parent
35db442cd3
commit
43469ef08d
33 files changed
+2748
-204
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user