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

+69
View File
@@ -0,0 +1,69 @@
import { useApi } from '@/composables/useApi'
import {
exportQuery,
listQuery,
summaryQuery,
type ActivityFilters,
type ActivityPage,
type ActivitySummary,
} from '@/lib/activity'
import type { UserListItem } from '@/composables/useUsers'
import type { ApiKey } from '@/composables/useApiKeys'
export interface PersonOptions {
users: { id: number; name: string }[]
keys: { id: number; name: string }[]
}
export const ACTIVITY_SKELETON_ROWS = 9
export const ACTIVITY_SKELETON_CARDS = 5
export const EXPORT_FILE_NAME = 'activity.csv'
export function useActivity() {
const { request, requestBlob } = useApi()
function listActivity(
filters: ActivityFilters,
page: number,
size: number,
withActor = true,
): Promise<ActivityPage> {
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
}
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
}
function exportActivity(filters: ActivityFilters): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
request<ApiKey[]>('/v1/api-key/'),
])
return {
users: users.flatMap((item) =>
item.kind === 'user'
? [{ id: item.id, name: item.display_name || item.username }]
: [],
),
keys: keys.map((key) => ({ id: key.id, name: key.name })),
}
}
return { listActivity, getSummary, exportActivity, listPeople }
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
+316
View File
@@ -0,0 +1,316 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import {
downloadBlob,
EXPORT_FILE_NAME,
useActivity,
type PersonOptions,
} from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
activeChips,
clearChip,
defaultFilters,
DEFAULT_PAGE_SIZE,
summaryRangeChanged,
type ActivityFilters,
type ActivityItem,
type ActivitySummary,
type ChipKey,
} from '@/lib/activity'
export type ListState = 'idle' | 'loading' | 'ready' | 'error' | 'forbidden'
export type SummaryState = 'idle' | 'loading' | 'ready' | 'failed'
const SKELETON_DELAY_MS = 150
const HANDLED_STATUSES = [401]
function delayedFlag() {
const flag = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
flag.value = false
}
function arm() {
stop()
timer = setTimeout(() => {
timer = null
flag.value = true
}, SKELETON_DELAY_MS)
}
return { flag, arm, stop }
}
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
const size = ref(DEFAULT_PAGE_SIZE)
const items = ref<ActivityItem[]>([])
const total = ref(0)
const listState = ref<ListState>('idle')
const summary = ref<ActivitySummary | null>(null)
const summaryState = ref<SummaryState>('idle')
const people = ref<PersonOptions | null>(null)
const exporting = ref(false)
const selectedId = ref<number | null>(null)
const navigating = ref(false)
const listSkeleton = delayedFlag()
const summarySkeleton = delayedFlag()
let listSequence = 0
let summarySequence = 0
let peopleRequested = false
let requestedPage = 1
const role = computed(() => session.session?.role as string | undefined)
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
const chips = computed(() => activeChips(filters.value))
const showListSkeleton = computed(
() => listState.value === 'loading' && listSkeleton.flag.value,
)
const showSummarySkeleton = computed(
() => summaryState.value === 'loading' && summarySkeleton.flag.value,
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
listState.value === 'error' ||
(isEmpty.value && chips.value.length === 0),
)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * size.value + 1))
const rangeEnd = computed(() => Math.min(total.value, page.value * size.value))
const selectedIndex = computed(() =>
items.value.findIndex((item) => item.id === selectedId.value),
)
const selectedItem = computed(() => items.value[selectedIndex.value] ?? null)
const position = computed(() =>
selectedIndex.value < 0 ? 0 : (page.value - 1) * size.value + selectedIndex.value + 1,
)
const canPrevious = computed(() => position.value > 1 && !navigating.value)
const canNext = computed(
() => position.value > 0 && position.value < total.value && !navigating.value,
)
function failureState(error: unknown): ListState {
if (error instanceof ApiError && HANDLED_STATUSES.includes(error.status)) return 'idle'
if (error instanceof ApiError && error.status === 403) {
return session.session?.verified === false ? 'idle' : 'forbidden'
}
return 'error'
}
async function fetchPage(
nextPage: number,
options: { silent?: boolean; select?: 'first' | 'last' } = {},
) {
const sequence = ++listSequence
if (!options.silent) {
requestedPage = nextPage
listState.value = 'loading'
listSkeleton.arm()
}
try {
const result = await listActivity(filters.value, nextPage, size.value, isFullView.value)
if (sequence !== listSequence) return false
items.value = result.items
total.value = result.total
page.value = nextPage
listState.value = 'ready'
if (options.select && result.items.length > 0) {
const target = options.select === 'first' ? 0 : result.items.length - 1
selectedId.value = result.items[target]!.id
}
return true
} catch (error) {
if (sequence !== listSequence) return false
if (!options.silent) listState.value = failureState(error)
return false
} finally {
if (sequence === listSequence) listSkeleton.stop()
}
}
function loadList() {
return fetchPage(1)
}
async function loadSummary() {
if (!isFullView.value) return
const sequence = ++summarySequence
summaryState.value = 'loading'
summarySkeleton.arm()
try {
const result = await getSummary(filters.value)
if (sequence !== summarySequence) return
summary.value = result
summaryState.value = 'ready'
} catch {
if (sequence !== summarySequence) return
summary.value = null
summaryState.value = 'failed'
} finally {
if (sequence === summarySequence) summarySkeleton.stop()
}
}
function start() {
loadList()
loadSummary()
}
function applyFilters(next: ActivityFilters, force = false) {
const current = filters.value
if (!force && JSON.stringify(next) === JSON.stringify(current)) return
const rangeChanged = summaryRangeChanged(current, next)
filters.value = next
loadList()
if (rangeChanged) loadSummary()
}
function removeChip(key: ChipKey) {
applyFilters(clearChip(filters.value, key))
}
function clearAll() {
applyFilters(defaultFilters(), true)
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
}
function setSize(next: number) {
if (next === size.value) return
size.value = next
fetchPage(1)
}
function retry() {
fetchPage(requestedPage)
}
async function ensurePeople() {
if (peopleRequested) return
peopleRequested = true
try {
people.value = await listPeople()
} catch {
peopleRequested = false
}
}
async function runExport() {
if (exportDisabled.value) return
exporting.value = true
try {
const blob = await exportActivity(filters.value)
downloadBlob(blob, EXPORT_FILE_NAME)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Export started. Your file will download shortly.')
},
}))
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't export activity. Try again.")
},
}))
}
} finally {
exporting.value = false
}
}
function openDetails(id: number) {
selectedId.value = id
}
function closeDetails() {
selectedId.value = null
}
async function step(direction: -1 | 1) {
const index = selectedIndex.value
if (index < 0 || navigating.value || listState.value === 'loading') return
const target = index + direction
const neighbour = items.value[target]
if (neighbour) {
selectedId.value = neighbour.id
return
}
if (direction === 1 ? position.value >= total.value : page.value <= 1) return
navigating.value = true
await fetchPage(page.value + direction, {
silent: true,
select: direction === 1 ? 'first' : 'last',
})
navigating.value = false
}
onBeforeUnmount(() => {
listSkeleton.stop()
summarySkeleton.stop()
})
return {
filters,
page,
size,
items,
total,
listState,
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
pageCount,
rangeStart,
rangeEnd,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
start,
applyFilters,
removeChip,
clearAll,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
next: () => step(1),
}
}
+13 -2
View File
@@ -101,7 +101,7 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
}
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
async function send(path: string, options: RequestOptions = {}): Promise<Response> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
@@ -132,6 +132,12 @@ export function useApi() {
throw new ApiError(response.status, undefined, await readErrorBody(response))
}
return response
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await send(path, options)
if (response.status === 204) {
return undefined as T
}
@@ -139,6 +145,11 @@ export function useApi() {
return response.json()
}
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await send(path, options)
return response.blob()
}
async function getMe(): Promise<MeResult> {
const body = await request<{
id?: number
@@ -158,5 +169,5 @@ export function useApi() {
}
}
return { getMe, request }
return { getMe, request, requestBlob }
}