Files
GerbilManager/gerbil-manager-web/src/hooks/useApi.ts
Gulum 5732398e60
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m54s
CI / Docker Build & Push (push) Has been skipped
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>
2026-06-13 01:05:44 +02:00

115 lines
3.6 KiB
TypeScript

/**
* Minimal async-data hooks (no react-query dependency).
*
* `useApi` runs an async loader and tracks loading/error/data, re-running when
* `deps` change. `reload()` re-fetches on demand (e.g. after a mutation).
*
* Design notes (to satisfy the strict react-hooks rules):
* - `loading` is DERIVED (resolved-key !== current-request-key), never set
* synchronously inside the effect.
* - the loader is read through a ref so the effect can key purely on the
* request signature without re-running on every render.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError } from '../api/client'
import { de } from '../strings/de'
export interface AsyncState<T> {
data: T | null
loading: boolean
error: string | null
reload: () => void
}
export function messageFor(err: unknown): string {
if (err instanceof ApiError) return err.message
if (err instanceof Error && err.message) return err.message
return de.api.errors.unknown
}
interface Resolved<T> {
key: string | null
data: T | null
error: string | null
}
export function useApi<T>(loader: () => Promise<T>, deps: unknown[]): AsyncState<T> {
const loaderRef = useRef(loader)
// Keep the loader ref current (declared before the request effect so it is
// synced first within a commit). Writing refs in render is disallowed.
useEffect(() => {
loaderRef.current = loader
})
const [nonce, setNonce] = useState(0)
const requestKey = `${nonce}:${JSON.stringify(deps)}`
const [resolved, setResolved] = useState<Resolved<T>>({ key: null, data: null, error: null })
useEffect(() => {
let cancelled = false
loaderRef
.current()
.then((result) => {
if (!cancelled) setResolved({ key: requestKey, data: result, error: null })
})
.catch((err) => {
if (!cancelled) setResolved({ key: requestKey, data: null, error: messageFor(err) })
})
return () => {
cancelled = true
}
}, [requestKey])
const reload = useCallback(() => setNonce((n) => n + 1), [])
const loading = resolved.key !== requestKey
return { data: resolved.data, loading, error: loading ? null : resolved.error, reload }
}
/**
* Outcome of a mutation. Discriminated on `ok` so callers MUST branch before
* touching `value` — this makes error handling compile-time-visible and means
* `run()` NEVER throws (no more unhandled rejections from async event handlers).
* Works for void mutations too (delete): success is `{ ok: true, value: undefined }`.
*/
export type MutationOutcome<T> =
| { ok: true; value: T }
| { ok: false; error: string; cause: unknown }
/**
* Wraps a mutation (create/update/delete) with pending + error state.
* `run()` resolves to a MutationOutcome and never rejects; `error` also drives
* inline UI as before. Call sites: `const r = await m.run(x); if (r.ok) …`.
*/
export function useMutation<TArgs extends unknown[], TResult>(
mutator: (...args: TArgs) => Promise<TResult>,
): {
run: (...args: TArgs) => Promise<MutationOutcome<TResult>>
pending: boolean
error: string | null
} {
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const run = useCallback(
async (...args: TArgs): Promise<MutationOutcome<TResult>> => {
setPending(true)
setError(null)
try {
const value = await mutator(...args)
return { ok: true, value }
} catch (err) {
const message = messageFor(err)
setError(message)
return { ok: false, error: message, cause: err }
} finally {
setPending(false)
}
},
[mutator],
)
return { run, pending, error }
}