Files
GerbilManager/gerbil-manager-web/src/pages/GerbilsPage.tsx
Gulum b0e92c363a fix(ux): zuverlässige Scroll-Wiederherstellung für Infinite-Scroll-Listen
Zwei Probleme bei der großen Rennmausliste (und allen Infinite-Scroll-Listen) nach
Zurück-Navigation behoben:

1) Event- statt Timer-basiert: Die Scroll-Wiederherstellung hängt sich jetzt per
   ResizeObserver an die Höhenänderung der Seite (Liste ist wieder da) und springt dann
   zur gemerkten Position — statt auf einen festen Timer zu warten. Bricht bei eigener
   Scroll-Eingabe ab; Sicherheitsnetz nach 15 s.

2) Infinite-Scroll baut Höhe wieder auf: useInfiniteList merkt sich (optionaler
   restoreKey) die geladene Seitenzahl je Query und lädt sie nach einer Zurück-
   Navigation wieder nach — sonst war die Liste nur 1 Seite hoch und die Position
   unerreichbar. Aktiviert für Rennmäuse, Kontakte, Würfe, Becken, Anfragen, Verträge.

tsc/eslint/vitest grün; e2e (Listen + Navigation, 41) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:01:28 +02:00

409 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils, listOriginBreeders } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { andFilter, condition, orGroup } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import {
GerbilFilterBar,
ActiveFilterChips,
BottomSheet,
type FilterCategory,
} from '../components/GerbilFilterBar'
import { useGerbilName } from '../components/breederSuffix'
import './gerbils.css'
const PAGE_SIZE = 20
// SEARCH-2b: separator-insensitive search via Pam's normalized field. The term
// is normalized the SAME way server-side stores it (lowercase + strip [\s._-]),
// so 'clan kleine chaoten' matches 'clan-kleine-chaoten'.
const NAME_SEARCH_FIELD = 'nameSearch'
const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '')
type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc'
// A trailing `id` makes every ordering a TOTAL order: rows that tie on name (or
// dateOfBirth) keep a stable position across page fetches, so OFFSET pagination
// never returns the same row on two pages (which caused duplicate React keys).
const SORT_ORDER_BY: Record<SortKey, string> = {
nameAsc: 'name,id',
nameDesc: 'name desc,id',
birthDesc: 'dateOfBirth desc,id',
birthAsc: 'dateOfBirth,id',
}
const SORT_KEYS: SortKey[] = ['nameAsc', 'nameDesc', 'birthDesc', 'birthAsc']
// DESIGN: per-status chip/dot/badge colours (from the prototype's status palette).
const STATUS_COLORS: Record<GerbilStatus, string> = {
Breeding: '#5e8c5a',
Pet: '#5a7da8',
ForSale: '#c2703d',
Deceased: '#8a847b',
GivenAway: '#b08e5c',
}
const GENDER_SYMBOL: Record<Gender, string> = { unknown: '?', male: '♂', female: '♀' }
// Multi-select filter state. Keys match Gridify field names so the query builds directly.
interface GerbilFilters {
[key: string]: string[]
status: GerbilStatus[]
gender: Gender[]
colorVarietyId: string[]
originBreeder: string[]
}
// BESTAND-FILTER: default to showing the own breeding stock (Status = Zucht).
const DEFAULT_FILTERS: GerbilFilters = {
status: ['Breeding'],
gender: [],
colorVarietyId: [],
originBreeder: [],
}
const EMPTY_FILTERS: GerbilFilters = { status: [], gender: [], colorVarietyId: [], originBreeder: [] }
export default function GerbilsPage() {
const t = de.pages.gerbils
const gerbilName = useGerbilName()
const [search, setSearch] = useState('')
const [filters, setFilters] = useState<GerbilFilters>(DEFAULT_FILTERS)
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
const [showExternal, setShowExternal] = useState(false)
// BESTAND-FILTER: optionally hide animals without a date of birth (server-side).
const [hideUndated, setHideUndated] = useState(false)
const [sort, setSort] = useState<SortKey>('nameAsc')
const [sortOpen, setSortOpen] = useState(false)
// DESIGN: the header (search + filters) is sticky; the title row blends out once
// the user scrolls. A 1px sentinel above the sticky header drives `condensed`
// via IntersectionObserver — robust regardless of which element actually scrolls.
const [condensed, setCondensed] = useState(false)
const topSentinelRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = topSentinelRef.current
if (!el) return
const io = new IntersectionObserver(([entry]) => setCondensed(!entry.isIntersecting))
io.observe(el)
return () => io.disconnect()
}, [])
// Back-to-top: show a floating button once the user has scrolled a screenful,
// and smooth-scroll the main area back to the top on tap.
const [showBackToTop, setShowBackToTop] = useState(false)
const scrollerOf = () =>
(topSentinelRef.current?.closest('.app-main') as HTMLElement | null) ?? null
useEffect(() => {
const scroller = scrollerOf()
const read = () => (scroller ? scroller.scrollTop : window.scrollY)
const target: HTMLElement | Window = scroller ?? window
const onScroll = () => setShowBackToTop(read() > 600)
target.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => target.removeEventListener('scroll', onScroll)
}, [])
const scrollToTop = () => (scrollerOf() ?? window).scrollTo({ top: 0, behavior: 'smooth' })
const colorVarieties = useApi(() => listColorVarieties(), [])
const colorNameById = useMemo(() => {
const map = new Map<string, string>()
for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name)
return map
}, [colorVarieties.data])
const breeders = useApi(() => listOriginBreeders(), [])
// DESIGN: filter categories for the Quick-Chips bar. `key` doubles as the Gridify field.
const categories: FilterCategory[] = useMemo(
() => [
{
key: 'status',
label: t.filters.status,
options: GERBIL_STATUSES.map((s) => ({
value: s,
label: statusLabel(s),
color: STATUS_COLORS[s],
})),
},
{
key: 'gender',
label: t.filters.gender,
options: GENDERS.map((g) => ({ value: g, label: genderLabel(g), symbol: GENDER_SYMBOL[g] })),
},
{
key: 'colorVarietyId',
label: t.filters.colorVariety,
searchable: true,
options: (colorVarieties.data ?? []).map((cv) => ({ value: cv.id, label: cv.name })),
},
{
key: 'originBreeder',
label: t.fields.origin,
searchable: true,
options: (breeders.data ?? []).map((b) => ({ value: b, label: b })),
},
],
[t, colorVarieties.data, breeders.data],
)
const filter = andFilter(
search.trim() &&
condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: normalizeSearch(search) }),
// Multi-select: values within a category are OR'd, categories are AND'd.
orGroup('status', filters.status),
orGroup('gender', filters.gender),
orGroup('colorVarietyId', filters.colorVarietyId),
orGroup('originBreeder', filters.originBreeder),
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
// restrict to resident animals (server-side, so paging/counts stay correct).
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
// Optionally drop animals without a date of birth. Gridify reads an empty
// value as null, so `dateOfBirth!=` means "date of birth is set".
hideUndated && condition({ field: 'dateOfBirth', op: '!=', value: '' }),
)
const orderBy = SORT_ORDER_BY[sort]
// Infinite scroll: page 1 loads now; further pages append as the sentinel scrolls
// into view. Changing the query signature (filter/sort) reloads from page 1.
const resetKey = `${filter}${orderBy}`
const list = useInfiniteList(
(page) => listGerbils({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
resetKey,
'gerbils',
)
const sentinelRef = useInfiniteSentinel(list)
// Filter mutations.
const toggleFilter = (key: string, value: string) => {
setFilters((f) => {
const arr = f[key as keyof GerbilFilters] as string[]
const next = arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]
return { ...f, [key]: next }
})
}
const removeFilter = (key: string, value: string) => {
setFilters((f) => ({
...f,
[key]: (f[key as keyof GerbilFilters] as string[]).filter((v) => v !== value),
}))
}
const clearAllFilters = () => setFilters(EMPTY_FILTERS)
const resetAll = () => {
setSearch('')
setFilters(DEFAULT_FILTERS)
setShowExternal(false)
setHideUndated(false)
setSort('nameAsc')
}
const chipColor = (key: string, value: string): string | undefined =>
key === 'status' ? STATUS_COLORS[value as GerbilStatus] : undefined
const { items, total } = list
return (
<section className="page gerbils-page">
{/* 1px sentinel above the sticky header — drives the title blend-out. */}
<div ref={topSentinelRef} className="gerbils-scrolltop" aria-hidden="true" />
<div className="gerbils-topbar">
<header className={`page-head gerbils-head${condensed ? ' gerbils-head--condensed' : ''}`}>
<div>
<h2>{t.title}</h2>
{!list.loading && (
<p className="muted">
{total} {t.countLabel}
</p>
)}
</div>
<Link to="/rennmaeuse/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
<div className="rmf-searchbar">
<span className="rmf-search-ico" aria-hidden="true">
</span>
<input
type="search"
className="rmf-searchfield"
placeholder={t.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label={t.fields.name}
/>
{search && (
<button
type="button"
className="rmf-search-clear"
onClick={() => setSearch('')}
aria-label={de.filterBar.searchClear}
>
×
</button>
)}
</div>
<GerbilFilterBar categories={categories} selected={filters} onToggle={toggleFilter} />
<div className="rm-bar">
<button
type="button"
className={`rmf-bar-chip${showExternal ? ' is-on' : ''}`}
onClick={() => setShowExternal((v) => !v)}
aria-pressed={showExternal}
title={t.filters.showExternalHint}
>
<span className="rmf-chip-toggle-ico" aria-hidden="true">
{showExternal ? '✓' : '+'}
</span>
<span className="rmf-bar-chip-label">{t.filters.showExternalShort}</span>
</button>
<button
type="button"
className={`rmf-bar-chip${hideUndated ? ' is-on' : ''}`}
onClick={() => setHideUndated((v) => !v)}
aria-pressed={hideUndated}
title={t.filters.hideUndatedHint}
>
<span className="rmf-chip-toggle-ico" aria-hidden="true">
{hideUndated ? '✓' : '+'}
</span>
<span className="rmf-bar-chip-label">{t.filters.hideUndatedShort}</span>
</button>
<button
type="button"
className="rmf-bar-chip"
onClick={() => setSortOpen(true)}
aria-haspopup="dialog"
>
<span className="rmf-bar-chip-ico" aria-hidden="true">
</span>
<span className="rmf-bar-chip-label">{t.sort[sort]}</span>
<span className="rmf-bar-chip-chev" aria-hidden="true">
</span>
</button>
</div>
</div>
{sortOpen && (
<BottomSheet title={t.filters.sortBy} onClose={() => setSortOpen(false)}>
<div className="rmf-optlist">
{SORT_KEYS.map((key) => (
<button
key={key}
type="button"
className={'rmf-opt' + (sort === key ? ' is-on' : '')}
onClick={() => {
setSort(key)
setSortOpen(false)
}}
aria-pressed={sort === key}
>
<span className="rmf-opt-box" aria-hidden="true">
{sort === key ? '✓' : ''}
</span>
<span className="rmf-opt-label">{t.sort[key]}</span>
</button>
))}
</div>
</BottomSheet>
)}
<ActiveFilterChips
categories={categories}
selected={filters}
onRemove={removeFilter}
onClearAll={clearAllFilters}
chipColor={chipColor}
/>
{list.loading && <p className="muted">{de.common.loading}</p>}
{list.error && (
<div className="alert alert--error">
<span>{list.error}</span>
<button type="button" className="btn" onClick={list.reload}>
{de.common.retry}
</button>
</div>
)}
{!list.loading && !list.error && items.length === 0 && (
<div className="rm-noresults">
<p className="muted">{t.empty}</p>
<button type="button" className="btn btn--ghost" onClick={resetAll}>
{t.filters.reset}
</button>
</div>
)}
{items.length > 0 && (
<ul className="card-list">
{items.map((g) => (
<li key={g.id} className="gerbil-row">
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<div className="gerbil-card__top">
<span className="gerbil-card__name">
{gerbilName(g) || t.nameless}
{g.isResident === false && (
<span className="badge badge--external" title={t.externalTitle}>
{t.externalBadge}
</span>
)}
</span>
<span className="gerbil-card__right">
<span
className={`gerbil-sex gerbil-sex--${g.gender}`}
title={genderLabel(g.gender)}
aria-label={genderLabel(g.gender)}
>
{GENDER_SYMBOL[g.gender]}
</span>
<span className={`badge badge--${g.status.toLowerCase()}`}>
{statusLabel(g.status)}
</span>
</span>
</div>
<div className="gerbil-card__meta">
<span className="gerbil-card__meta-strong">
{g.colorVarietyId ? (colorNameById.get(g.colorVarietyId) ?? '—') : '—'}
</span>
<span className="gerbil-card__dot" aria-hidden="true">
·
</span>
<span>{g.originBreeder || '—'}</span>
<span className="gerbil-card__dot" aria-hidden="true">
·
</span>
<span>
{t.born} {formatDate(g.dateOfBirth)}
</span>
</div>
</Link>
</li>
))}
</ul>
)}
{/* Infinite-scroll sentinel + "loading more" indicator. */}
{list.hasMore && <div ref={sentinelRef} className="rmf-sentinel" aria-hidden="true" />}
{list.loadingMore && <p className="muted rmf-loadmore">{de.common.loading}</p>}
{showBackToTop && (
<button
type="button"
className="rmf-backtotop"
onClick={scrollToTop}
aria-label={t.backToTop}
title={t.backToTop}
>
</button>
)}
</section>
)
}