Filter-UX aus dem Design: - Quick-Chip-Kategorien (Status/Geschlecht/Farbschlag/Herkunft) öffnen ein Bottom-Sheet mit Mehrfachauswahl (suchbar bei Farbschlag/Herkunft); aktive Filter als entfernbare Chips; Sortierung + "Externe Ahnen" als gleich große Chips (Sortierung öffnet dasselbe Drawer). - Neue zweizeilige Tierkarte (Name + Geschlechts-Kreis + Status-Badge, Meta-Zeile), cardIn/Chip/Sheet-Animationen, Titel blendet beim Scrollen aus. App-weit: - Design-Palette (Creme/Braun/Terrakotta), Hanken-Grotesk-Schrift und gemeinsamer --shadow-Token; obere "Rennmaus-Manager"-Titelleiste entfernt (mobil bleibt die untere Aktionsleiste); fixe Shell-Höhe, nur der Hauptbereich scrollt. Funktion: - Infinite Scroll via useInfiniteList (seitenweise) + IntersectionObserver-Sentinel. - Namensgenerator: Option männlich/weiblich/egal. - Mehrfach-OR-Filter über gridify orGroup; e2e an die neue UI angepasst. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
/**
|
|
* Server-paginated infinite list (no react-query).
|
|
*
|
|
* `loader(page)` fetches ONE page; pages are accumulated into `items`. The list
|
|
* never loads everything up front — `loadMore()` (wired to a scroll sentinel)
|
|
* advances exactly one page at a time until `hasMore` is false.
|
|
*
|
|
* Changing `resetKey` (e.g. the filter/sort/search signature) clears the list and
|
|
* reloads from page 1. `reload()` re-fetches page 1 on demand (e.g. retry).
|
|
*
|
|
* Design mirrors useApi (and satisfies the strict react-hooks rules):
|
|
* - the request signature lives in `req`; the query reset on `resetKey` change is
|
|
* an in-render state adjustment (not a setState-in-effect), so no cascade.
|
|
* - `loading` / `loadingMore` are DERIVED from resolved-vs-current request keys,
|
|
* never set synchronously inside the fetch effect.
|
|
* - the loader is read through a ref so the effect keys purely on the request id.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { messageFor } from './useApi'
|
|
import type { Paged } from '../api/types'
|
|
|
|
export interface InfiniteListState<T> {
|
|
items: T[]
|
|
total: number
|
|
/** Initial / reset load (page 1) in flight. */
|
|
loading: boolean
|
|
/** A subsequent page is being appended. */
|
|
loadingMore: boolean
|
|
error: string | null
|
|
hasMore: boolean
|
|
/** Advance one page; the scroll sentinel calls this. */
|
|
loadMore: () => void
|
|
/** Re-fetch from page 1 (retry). */
|
|
reload: () => void
|
|
}
|
|
|
|
interface Req {
|
|
key: string
|
|
page: number
|
|
nonce: number
|
|
}
|
|
|
|
interface Resolved<T> {
|
|
reqId: string
|
|
items: T[]
|
|
total: number
|
|
error: string | null
|
|
}
|
|
|
|
export function useInfiniteList<T>(
|
|
loader: (page: number) => Promise<Paged<T>>,
|
|
resetKey: string,
|
|
): InfiniteListState<T> {
|
|
const loaderRef = useRef(loader)
|
|
useEffect(() => {
|
|
loaderRef.current = loader
|
|
})
|
|
|
|
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
|
|
// Adjust state during render when the query signature changes — the recommended
|
|
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
|
|
if (req.key !== resetKey) {
|
|
setReq({ key: resetKey, page: 1, nonce: 0 })
|
|
}
|
|
|
|
const queryId = `${req.nonce}:${req.key}`
|
|
const reqId = `${queryId}#${req.page}`
|
|
|
|
const [resolved, setResolved] = useState<Resolved<T>>({
|
|
reqId: '',
|
|
items: [],
|
|
total: 0,
|
|
error: null,
|
|
})
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const page = req.page
|
|
const id = `${req.nonce}:${req.key}#${page}`
|
|
loaderRef
|
|
.current(page)
|
|
.then((res) => {
|
|
if (cancelled) return
|
|
setResolved((prev) => ({
|
|
reqId: id,
|
|
// page 1 replaces (fresh query); later pages append.
|
|
items: page === 1 ? res.items : [...prev.items, ...res.items],
|
|
total: res.totalCount,
|
|
error: null,
|
|
}))
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [req]) // req is a fresh object per change (key/page/nonce) → one fetch per change
|
|
|
|
// Resolved data only counts if it belongs to the current query signature.
|
|
const resolvedQueryId = resolved.reqId.slice(0, resolved.reqId.lastIndexOf('#'))
|
|
const sameQuery = resolved.reqId !== '' && resolvedQueryId === queryId
|
|
const inFlight = resolved.reqId !== reqId
|
|
|
|
const items = sameQuery ? resolved.items : []
|
|
const total = sameQuery ? resolved.total : 0
|
|
const loading = !sameQuery || (inFlight && req.page === 1)
|
|
const loadingMore = sameQuery && inFlight && req.page > 1
|
|
const error = inFlight ? null : sameQuery ? resolved.error : null
|
|
const hasMore = sameQuery && !inFlight && items.length < total
|
|
|
|
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), [])
|
|
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
|
|
|
|
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
|
|
}
|