🚧 Add better page browsing

This commit is contained in:
Jonas Bengtsson
2026-09-06 21:02:42 +02:00
parent f01f8a5ef6
commit 67a06e1023
10 changed files with 346 additions and 24 deletions
+3
View File
@@ -35,6 +35,9 @@ export interface MessageItem {
}
export interface MessagePage {
// total matching the CURRENT filters (not the unfiltered count) — feeds
// the pager's page count and last-page jump
total: number
limit: number
offset: number
items: MessageItem[]
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
// Shared pagination footer: first/prev/page-input/next/last buttons plus
// the rows-per-page selector (10/25/50/100/200 or manual entry, default 25,
// persisted via prefs.ts). Used by every table EXCEPT the dashboard's
// fixed "latest messages" list.
//
// Bound to offset+limit (both server-side and client-side tables can
// express their position that way) and a total matching the CURRENT
// filters/sort.
import { computed, ref, watch } from 'vue'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
import { MAX_LIMIT, PAGE_SIZES, loadPageSize, savePageSize } from '../prefs'
const props = defineProps<{
offset: number
limit: number
total: number
loading?: boolean
}>()
const emit = defineEmits<{
(e: 'update:offset', value: number): void
(e: 'update:limit', value: number): void
}>()
// Text shown while a page load is in flight or the total is unknown yet.
const UNKNOWN = '…'
const pages = computed(() => Math.max(1, Math.ceil(props.total / props.limit)))
const page = computed(() => Math.floor(props.offset / props.limit) + 1)
const firstItem = computed(() => (props.total === 0 ? 0 : props.offset + 1))
const lastItem = computed(() => Math.min(props.offset + props.limit, props.total))
const known = computed(() => !props.loading && (props.total as number) >= 0)
function clampPage(p: number): number {
return Math.min(Math.max(1, Math.floor(p)), pages.value)
}
function go(to: number): void {
const target = clampPage(to)
if (target !== page.value || props.offset !== (target - 1) * props.limit) {
emit('update:offset', (target - 1) * props.limit)
}
}
// Manual page entry: kept as a string so the field can be cleared while
// typing; applied (clamped) on Enter/blur.
const pageInput = ref('')
watch(
() => [props.offset, props.limit] as const,
() => {
pageInput.value = ''
},
)
function applyPageInput(): void {
const n = parseInt(pageInput.value, 10)
if (Number.isNaN(n)) {
pageInput.value = ''
return
}
pageInput.value = ''
go(n)
}
// Rows-per-page: presets plus a "custom…" option revealing a numeric input.
// Changing the size KEEPS the user's position (the row at the top of the
// current viewport stays on screen) instead of snapping back to page 1.
const sizeSelect = ref<number | 'custom'>(
PAGE_SIZES.includes(props.limit) ? props.limit : 'custom',
)
const customSize = ref(PAGE_SIZES.includes(props.limit) ? '' : String(props.limit))
// keep the select in sync when a parent restores a persisted size
watch(
() => props.limit,
(v) => {
sizeSelect.value = PAGE_SIZES.includes(v) ? v : 'custom'
customSize.value = PAGE_SIZES.includes(v) ? '' : String(v)
},
)
function onSizeSelect(): void {
if (sizeSelect.value === 'custom') return
applySize(sizeSelect.value)
}
function applyCustomSize(): void {
const n = parseInt(customSize.value, 10)
if (Number.isNaN(n)) {
customSize.value = String(props.limit)
return
}
customSize.value = ''
applySize(n)
}
function applySize(n: number): void {
const size = Math.min(Math.max(1, Math.floor(n)), MAX_LIMIT)
if (size === props.limit) return
savePageSize(size)
// keep the current top row visible: offset = its (0-based) index rounded
// down to a multiple of the new page size
emit('update:offset', Math.floor(props.offset / size) * size)
emit('update:limit', size)
}
</script>
<template>
<div class="row pager" style="justify-content: space-between">
<div class="row" style="gap: 0.4rem">
<button
class="ghost"
title="First page"
:disabled="loading || page <= 1"
@click="go(1)"
>
<ChevronsLeft :size="15" />
</button>
<button
class="ghost"
title="Previous page"
:disabled="loading || page <= 1"
@click="go(page - 1)"
>
<ChevronLeft :size="15" />
</button>
<span class="muted">page</span>
<input
v-model="pageInput"
class="mono page-input"
inputmode="numeric"
:placeholder="known ? String(page) : UNKNOWN"
:disabled="loading || !known"
@keydown.enter.prevent="applyPageInput"
@blur="applyPageInput"
/>
<span class="muted">/ {{ known ? pages : UNKNOWN }}</span>
<button
class="ghost"
title="Next page"
:disabled="loading || page >= pages"
@click="go(page + 1)"
>
<ChevronRight :size="15" />
</button>
<button
class="ghost"
title="Last page"
:disabled="loading || page >= pages"
@click="go(pages)"
>
<ChevronsRight :size="15" />
</button>
</div>
<div class="row" style="gap: 0.4rem">
<span v-if="known" class="muted">
{{ firstItem }}–{{ lastItem }} of {{ total }}
</span>
<span class="muted">rows</span>
<select
v-model="sizeSelect"
class="size-select"
style="width: auto"
@change="onSizeSelect"
>
<option v-for="s in PAGE_SIZES" :key="s" :value="s">{{ s }}</option>
<option value="custom">custom…</option>
</select>
<input
v-if="sizeSelect === 'custom'"
v-model="customSize"
class="mono"
inputmode="numeric"
placeholder="rows"
style="width: 5rem"
@keydown.enter.prevent="applyCustomSize"
@blur="applyCustomSize"
/>
</div>
</div>
</template>
<style scoped>
.page-input {
width: 4.5rem;
padding: 0.25rem 0.4rem;
text-align: center;
}
.size-select {
padding: 0.25rem 0.4rem;
}
.pager button {
padding: 0.25rem 0.45rem;
}
</style>
+32 -1
View File
@@ -7,7 +7,9 @@
// sort: 'string' | 'number' (default 'string')
// filter: 'text' | 'facet' | 'text+facet' (default: no filtering)
import { computed, reactive, ref, type Ref } from 'vue'
import { computed, reactive, ref, watch, type Ref } from 'vue'
import { loadPageSize, savePageSize } from '../prefs'
export type FilterKind = 'text' | 'facet' | 'text+facet'
@@ -87,6 +89,31 @@ export function useTable<T>(rows: Ref<T[]>, columns: Record<string, ColumnConfig
return out
})
// Client-side pagination over the filtered rows (shared by the fully-
// loaded tables: Devices, Users). One global persisted page size (see
// prefs.ts); offset is in rows, kept valid by the watchers when filters
// shrink or grow the result set.
const limit = ref(loadPageSize())
const offset = ref(0)
const pageRows = computed(() =>
sortedFilteredRows.value.slice(offset.value, offset.value + limit.value),
)
// The last page's offset is floor((n-1)/limit)*limit - NOT n-limit, which
// would land mid-page (60 rows / 25 -> 35 instead of 50).
watch(sortedFilteredRows, (rows) => {
const last = rows.length
? Math.floor((rows.length - 1) / limit.value) * limit.value
: 0
if (offset.value > last) offset.value = last
})
watch(limit, (v) => savePageSize(v))
function setPageSize(n: number): void {
// keep the current top row visible across the size change
offset.value = Math.floor(offset.value / n) * n
limit.value = n
}
function clearColumn(name: string): void {
const st = state[name]
if (!st) return
@@ -106,6 +133,10 @@ export function useTable<T>(rows: Ref<T[]>, columns: Record<string, ColumnConfig
toggleSort,
distinctValues,
sortedFilteredRows,
pageRows,
limit,
offset,
setPageSize,
clearColumn,
activeFilterCount,
}
+21
View File
@@ -0,0 +1,21 @@
// Tiny localStorage-backed preference helpers (namespaced like the theme
// key in theme.ts). One GLOBAL page size is shared by all paginated views;
// per-view values would be over-engineering.
const PAGE_SIZE_KEY = 'nodeledge-ui-page-size'
export const PAGE_SIZES = [10, 25, 50, 100, 200]
// Keep manual entries inside what the API accepts (limit > 0) and inside
// the server cap API_MAX_LIMIT (config.max_limit, default 10000).
export const MAX_LIMIT = 10000
export function loadPageSize(): number {
const raw = localStorage.getItem(PAGE_SIZE_KEY)
const n = raw === null ? NaN : Number(raw)
if (Number.isInteger(n) && n >= 1 && n <= MAX_LIMIT) return n
return 25
}
export function savePageSize(n: number): void {
localStorage.setItem(PAGE_SIZE_KEY, String(n))
}
+5
View File
@@ -309,6 +309,11 @@ button:disabled {
flex-wrap: wrap;
}
.pager {
margin-top: 0.75rem;
gap: 0.75rem;
}
.grow {
flex: 1;
}
+10 -1
View File
@@ -5,6 +5,7 @@ import { api } from '../api'
import type { Device } from '../api'
import { useTable } from '../composables/useTable'
import TableHeader from '../components/TableHeader.vue'
import Pager from '../components/Pager.vue'
import { canEdit, showError, toast } from '../store'
const devices = ref<Device[]>([])
@@ -121,7 +122,7 @@ async function removeDevice(d: Device) {
</tr>
</thead>
<tbody>
<tr v-for="d in table.sortedFilteredRows.value" :key="d.imei">
<tr v-for="d in table.pageRows.value" :key="d.imei">
<td class="mono">
<router-link :to="`/devices/${d.imei}`">{{ d.imei }}</router-link>
</td>
@@ -143,5 +144,13 @@ async function removeDevice(d: Device) {
<div class="skeleton line" />
<div class="skeleton line" />
</template>
<Pager
v-if="loaded && devices.length"
:offset="table.offset.value"
:limit="table.limit.value"
:total="table.sortedFilteredRows.value.length"
@update:offset="(v) => (table.offset.value = v)"
@update:limit="table.setPageSize"
/>
</div>
</template>
+44 -18
View File
@@ -5,12 +5,16 @@ import { api } from '../api'
import type { Device, MessagePage } from '../api'
import { formatTime } from '../format'
import { showError } from '../store'
import { loadPageSize } from '../prefs'
import TableHeader from '../components/TableHeader.vue'
import Pager from '../components/Pager.vue'
const page = ref<MessagePage | null>(null)
const loading = ref(false)
const offset = ref(0)
const limit = 25
// persisted global page size (prefs.ts); the server caps it at
// API_MAX_LIMIT (default 10000)
const limit = ref(loadPageSize())
// the full device population for the facet lists (not just the loaded page)
const devices = ref<Device[]>([])
@@ -35,7 +39,7 @@ const to = ref('')
function buildQuery(): string {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('limit', String(limit.value))
params.set('offset', String(offset.value))
if (sort.value) params.set('sort', sort.value)
for (const v of imeiChecked.value) params.append('imei', v)
@@ -47,17 +51,34 @@ function buildQuery(): string {
return `/api/messages?${params.toString()}`
}
// Server-side loads can overlap (e.g. the Pager's size change updates offset
// and limit at once). A monotonically increasing token makes stale responses
// harmless: only the newest request may write page.value.
let loadSeq = 0
async function load() {
const seq = ++loadSeq
loading.value = true
try {
page.value = await api<MessagePage>(buildQuery())
const body = await api<MessagePage>(buildQuery())
if (seq !== loadSeq) return // a newer request superseded this one
page.value = body
} catch (e) {
showError(e)
if (seq === loadSeq) showError(e)
} finally {
loading.value = false
if (seq === loadSeq) loading.value = false
}
}
// Coalesces a burst of state updates (a Pager size change emits BOTH
// update:offset and update:limit) into one request built from the FINAL
// state — no intermediate request with a half-updated offset/limit.
let reloadTimer: number | undefined
function loadSoon(): void {
window.clearTimeout(reloadTimer)
reloadTimer = window.setTimeout(load, 0)
}
function applyFilters() {
offset.value = 0
load()
@@ -104,14 +125,18 @@ function onText(kind: 'imei' | 'device', text: string) {
textTimer = window.setTimeout(applyFilters, 400)
}
function prev() {
offset.value = Math.max(0, offset.value - limit)
load()
// Pager callbacks: a size change emits BOTH update:offset (position kept,
// top row stays visible) and update:limit — loadSoon() merges them into a
// single request; a lone offset is a plain page jump (also coalesced, so
// fast clicking cannot queue one request per click).
function onOffset(newOffset: number): void {
offset.value = newOffset
loadSoon()
}
function next() {
offset.value += limit
load()
function onLimit(newLimit: number): void {
limit.value = newLimit
loadSoon()
}
onMounted(async () => {
@@ -228,13 +253,14 @@ onMounted(async () => {
</tr>
</tbody>
</table>
<div class="row" style="margin-top: 0.75rem">
<button :disabled="offset === 0 || loading" @click="prev">← Newer</button>
<span class="muted">items {{ offset + 1 }}–{{ offset + (page?.items.length ?? 0) }}</span>
<button :disabled="(page?.items.length ?? 0) < limit || loading" @click="next">
Older →
</button>
</div>
<Pager
:offset="offset"
:limit="limit"
:total="page?.total ?? 0"
:loading="loading"
@update:offset="onOffset"
@update:limit="onLimit"
/>
</template>
</div>
</template>
+10 -1
View File
@@ -4,6 +4,7 @@ import { Plus, Trash2, UserRound } from 'lucide-vue-next'
import { api } from '../api'
import { useTable } from '../composables/useTable'
import TableHeader from '../components/TableHeader.vue'
import Pager from '../components/Pager.vue'
import { canEdit, isAdmin, showError, toast, ui } from '../store'
const users = ref<{ username: string; role: string }[]>([])
@@ -143,7 +144,7 @@ async function removeUser(u: { username: string; role: string }) {
</tr>
</thead>
<tbody>
<tr v-for="u in table.sortedFilteredRows.value" :key="u.username">
<tr v-for="u in table.pageRows.value" :key="u.username">
<td class="mono">{{ u.username }}</td>
<td>
<span class="badge" :class="{ ok: u.role === 'admin' }">{{ u.role }}</span>
@@ -164,5 +165,13 @@ async function removeUser(u: { username: string; role: string }) {
<template v-else>
<div class="skeleton line" />
</template>
<Pager
v-if="loaded && users.length"
:offset="table.offset.value"
:limit="table.limit.value"
:total="table.sortedFilteredRows.value.length"
@update:offset="(v) => (table.offset.value = v)"
@update:limit="table.setPageSize"
/>
</div>
</template>