Files
GerbilManager/gerbil-manager-web/src/pages/GerbilsPage.tsx

387 lines
14 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 './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 {
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 [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)
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 }),
)
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,
)
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)
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"
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">
{g.name || 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>
)
}