Files
inkwell/frontend/src/components/ActivityList.vue
T
bvandeusenandClaude Opus 5.5 eb6a1ff3ef Web: Activity loads older events as you scroll, no Show older button
Preference 172. ScrollSentinel watches the end of a cursor-paged list and
asks for the next page a screen early. It asks once per cursor, keeps going
when a page lands without pushing the end off screen, and stops on a failure
until Try again (an observer re-firing on a failure is a request loop). A
failed older page now gets its own error beside the retry, instead of
replacing the list's load error.

Adapted from fabledstash's ScrollSentinel (snippet 3760).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:19 -04:00

122 lines
3.7 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { formatShortDateTime } from "../notes/datetime";
import { useLoad } from "../composables/useAction";
import ScrollSentinel from "./ScrollSentinel.vue";
// Admin: what has happened to accounts on this instance, newest first (#2939 §5).
// Kept for `audit_retention_days` (Settings → Security).
interface AuditEvent {
id: string;
at: string;
event: string;
email: string | null;
address: string | null;
detail: string | null;
}
// One phrase per event name in `audit.py`. A new event needs a line here.
const WORDS: Record<string, string> = {
sign_in: "Signed in",
sign_in_failed: "Sign-in failed",
registered: "Account created",
registration_refused: "Sign-up refused",
reset_requested: "Reset email asked for",
reset_link_made: "Reset link made",
password_reset: "Password reset",
reset_refused: "Reset link refused",
password_changed: "Password changed",
password_change_refused: "Password change refused",
signed_out_elsewhere: "Signed out elsewhere",
device_linked: "Device linked",
device_unlinked: "Device unlinked",
invite_created: "Invite made",
invite_revoked: "Invite revoked",
};
// Shown in red: something was tried and refused.
const REFUSALS = new Set([
"sign_in_failed",
"registration_refused",
"reset_refused",
"password_change_refused",
]);
const events = ref<AuditEvent[]>([]);
const more = ref(false);
const { loading, error, load } = useLoad(async () => {
const res = await page();
events.value = res.events;
more.value = res.more;
}, "Couldn't load activity.");
const loadingMore = ref(false);
const moreError = ref("");
// The next page starts before the oldest event shown; empty once there is none.
const cursor = computed(() => (more.value ? (events.value[events.value.length - 1]?.at ?? "") : ""));
async function page(before?: string) {
const query = before ? `?before=${encodeURIComponent(before)}` : "";
return api.get<{ events: AuditEvent[]; more: boolean }>(`/api/accounts/activity${query}`);
}
async function loadMore() {
if (!cursor.value || loadingMore.value) return;
loadingMore.value = true;
moreError.value = "";
try {
const res = await page(cursor.value);
events.value.push(...res.events);
more.value = res.more;
} catch (e) {
moreError.value = errorMessage(e, "Couldn't load older activity.");
} finally {
loadingMore.value = false;
}
}
function where(e: AuditEvent): string {
return [formatShortDateTime(e.at), e.address, e.detail].filter(Boolean).join(" · ");
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="section-label">Activity</h2>
<p v-if="error" class="form-error">{{ error }}</p>
<div v-if="loading" class="list-empty">Loading…</div>
<p v-else-if="!events.length" class="text-sm text-neutral-400">Nothing yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="e in events"
:key="e.id"
class="rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
<span :class="REFUSALS.has(e.event) ? 'text-red-600 dark:text-red-400' : ''">
{{ WORDS[e.event] ?? e.event }}
</span>
<span v-if="e.email" class="font-normal text-neutral-500"> · {{ e.email }}</span>
</p>
<p class="truncate text-xs text-neutral-400">{{ where(e) }}</p>
</li>
</ul>
<ScrollSentinel
v-if="!loading"
:cursor="cursor"
:loading="loadingMore"
:error="moreError"
@more="loadMore"
/>
</section>
</template>