387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
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} |