FEAT: Merge Anja Sch. into Anja Schaumburg, and keep infinite scroll layout pages

This commit is contained in:
2026-06-13 08:14:31 +02:00
parent f3f4534392
commit c6968b9968
5 changed files with 26 additions and 57 deletions

View File

@@ -1,10 +1,10 @@
/** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */ /** FEAT-2: Kontakte-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
import { useEffect, useRef, useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { listContactsPaged } from '../api/contacts' import { listContactsPaged } from '../api/contacts'
import { condition } from '../api/gridify' import { condition } from '../api/gridify'
import { useInfiniteList } from '../hooks/useInfiniteList' import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -21,21 +21,8 @@ export default function KontaktePage() {
(page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }), (page) => listContactsPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`, `${filter ?? ''}`,
) )
const { items, total, hasMore, loading, loadingMore, loadMore } = list const sentinelRef = useInfiniteSentinel(list)
const { items, total, hasMore, loading, loadingMore } = list
const sentinelRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = sentinelRef.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) loadMore()
},
{ rootMargin: '300px 0px' },
)
io.observe(el)
return () => io.disconnect()
}, [hasMore, loading, loadingMore, loadMore])
return ( return (
<section className="page"> <section className="page">

View File

@@ -1,5 +1,5 @@
/** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */ /** FEAT-13: Abgabeverträge — Liste mit Download/Löschen (/vertraege). */
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts' import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'

View File

@@ -3,9 +3,10 @@ import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { listLitters } from '../api/litters' import { listLitters } from '../api/litters'
import { listGerbils } from '../api/gerbils' import { listGerbils } from '../api/gerbils'
import { andFilter, condition, escapeGridifyValue, type GridifyQuery } from '../api/gridify' import { andFilter, condition, escapeGridifyValue } from '../api/gridify'
import type { Litter } from '../api/types' import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate } from '../format/labels' import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel' import { FilterPanel } from '../components/FilterPanel'
@@ -13,7 +14,8 @@ const PAGE_SIZE = 20
type Tab = 'litters' | 'pairs' type Tab = 'litters' | 'pairs'
type SortKey = 'dateDesc' | 'dateAsc' type SortKey = 'dateDesc' | 'dateAsc'
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc', dateAsc: 'date' } // Trailing `id` → total order, so OFFSET pagination is stable for infinite scroll.
const SORT_ORDER_BY: Record<SortKey, string> = { dateDesc: 'date desc,id', dateAsc: 'date,id' }
function yearOptions(): number[] { function yearOptions(): number[] {
const current = new Date().getFullYear() const current = new Date().getFullYear()
@@ -50,7 +52,6 @@ export default function WuerfeListPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [year, setYear] = useState('') const [year, setYear] = useState('')
const [sort, setSort] = useState<SortKey>('dateDesc') const [sort, setSort] = useState<SortKey>('dateDesc')
const [page, setPage] = useState(1)
// Parent names (shared by both tabs). // Parent names (shared by both tabs).
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), []) const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
@@ -68,8 +69,11 @@ export default function WuerfeListPage() {
year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }), year && condition({ field: 'date', op: '<=', value: `${year}-12-31` }),
) )
const orderBy = SORT_ORDER_BY[sort] const orderBy = SORT_ORDER_BY[sort]
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } const litters = useInfiniteList(
const litters = useApi(() => listLitters(query), [filter, orderBy, page]) (page) => listLitters({ filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }),
`${filter}|${orderBy}`,
)
const littersSentinelRef = useInfiniteSentinel(litters)
// Pairs tab: all litters, grouped client-side. // Pairs tab: all litters, grouped client-side.
const allLitters = useApi( const allLitters = useApi(
@@ -88,9 +92,7 @@ export default function WuerfeListPage() {
) )
}, [pairs, search, nameById]) }, [pairs, search, nameById])
const total = litters.data?.totalCount ?? 0 const items = litters.items
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = litters.data?.items ?? []
return ( return (
<section className="page"> <section className="page">
@@ -131,10 +133,7 @@ export default function WuerfeListPage() {
className="input" className="input"
placeholder={t.searchPlaceholder} placeholder={t.searchPlaceholder}
value={search} value={search}
onChange={(e) => { onChange={(e) => setSearch(e.target.value)}
setSearch(e.target.value)
setPage(1)
}}
aria-label="Wurf suchen" aria-label="Wurf suchen"
/> />
} }
@@ -143,17 +142,13 @@ export default function WuerfeListPage() {
setSearch('') setSearch('')
setYear('') setYear('')
setSort('dateDesc') setSort('dateDesc')
setPage(1)
}} }}
> >
<label className="field"> <label className="field">
<span>{t.filterYear}</span> <span>{t.filterYear}</span>
<select <select
value={year} value={year}
onChange={(e) => { onChange={(e) => setYear(e.target.value)}
setYear(e.target.value)
setPage(1)
}}
> >
<option value="">{t.allYears}</option> <option value="">{t.allYears}</option>
{yearOptions().map((y) => ( {yearOptions().map((y) => (
@@ -204,28 +199,14 @@ export default function WuerfeListPage() {
</ul> </ul>
)} )}
{totalPages > 1 && ( {/* Infinite-scroll sentinel + "loading more" indicator. */}
<nav className="pager" aria-label={de.common.pageNav}> {litters.hasMore && (
<button <div ref={littersSentinelRef} aria-hidden="true" style={{ height: 1 }} />
type="button" )}
className="btn" {litters.loadingMore && (
disabled={page <= 1} <p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
onClick={() => setPage((p) => Math.max(1, p - 1))} {de.common.loading}
> </p>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
)} )}
</> </>
)} )}

View File

@@ -89,6 +89,7 @@ def get_normalized_contact_name(name):
"blackforestgv": "Black Forest", "blackforestgv": "Black Forest",
"topol": "Topolino", "topol": "Topolino",
"ankeb": "Anke Busch", "ankeb": "Anke Busch",
"anjasch": "Anja Schaumburg",
} }
if n in norm_map: if n in norm_map: