🚧 Add better page browsing
This commit is contained in:
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -309,6 +309,11 @@ button:disabled {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user