feat(rennmaeuse): Tiere-Liste neu im Rennmaus-Filter-Design
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>
This commit is contained in:
116
gerbil-manager-web/src/hooks/useInfiniteList.ts
Normal file
116
gerbil-manager-web/src/hooks/useInfiniteList.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user