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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
@@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
|
||||
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
|
||||
|
||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||
await expect(maxRow).toBeVisible()
|
||||
|
||||
@@ -1,85 +1,54 @@
|
||||
/**
|
||||
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
|
||||
*
|
||||
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
|
||||
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
|
||||
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.describe('Quick-Chips-Filter – Rennmäuse-Liste', () => {
|
||||
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Toggle-Button sichtbar.
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
const quickrow = page.locator('.rmf-quickrow')
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Öffnen → Status-Feld wird sichtbar.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
// Default: Zucht-Chip ist aktiv.
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||
|
||||
// Schließen → wieder verborgen.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
|
||||
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
|
||||
|
||||
// „Alle löschen" entfernt sämtliche Chips.
|
||||
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
|
||||
await expect(page.locator('.rmf-chip')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
|
||||
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||
await toggle.click()
|
||||
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||
await expect(toggle).toHaveText('Filter (1)')
|
||||
|
||||
// Zurücksetzen → Badge weg.
|
||||
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
const breedingChip = page.locator('.rmf-chip', {
|
||||
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
|
||||
})
|
||||
|
||||
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'desktop') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeHidden()
|
||||
|
||||
// Status-Beschriftung direkt sichtbar.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
await expect(breedingChip).toBeVisible()
|
||||
await breedingChip.click()
|
||||
await expect(breedingChip).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
|
||||
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
|
||||
*/
|
||||
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
|
||||
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
|
||||
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
|
||||
const sheet = page.getByRole('dialog', { name: categoryLabel })
|
||||
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
|
||||
await sheet.getByRole('button', { name: de.filterBar.done }).click()
|
||||
}
|
||||
|
||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||
export const uniqueName = (prefix: string) =>
|
||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
|
||||
@@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures'
|
||||
|
||||
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
||||
// DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains.
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
|
||||
@@ -37,10 +37,8 @@ test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', as
|
||||
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
const statusSelect = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.filters.status}")`) })
|
||||
.locator('select')
|
||||
await expect(statusSelect).toHaveValue('Breeding')
|
||||
// DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip.
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
const tabs = de.pages.tierTabs
|
||||
@@ -23,13 +23,9 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||
await page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||
.locator('select')
|
||||
.selectOption({ label: 'Clan-Kleine-Chaoten' })
|
||||
// DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet
|
||||
// auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern.
|
||||
await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten')
|
||||
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
|
||||
<!-- DESIGN (Rennmaus Filter): Hanken Grotesk als App-Schrift. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Rennmaus-Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -61,6 +61,23 @@ export function andFilter(...conditions: Array<string | null | undefined | false
|
||||
return conditions.filter((c): c is string => Boolean(c)).join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OR-group of conditions for one field, e.g. `(status=Breeding|status=ForSale)`.
|
||||
* Used for multi-select filters: several allowed values within one category are OR'd,
|
||||
* while categories are AND'd together by {@link andFilter}.
|
||||
* Returns '' for an empty value list (so andFilter drops it); a single value needs no parens.
|
||||
*/
|
||||
export function orGroup(
|
||||
field: string,
|
||||
values: ReadonlyArray<string | number | boolean>,
|
||||
op: GridifyOp = '==',
|
||||
): string {
|
||||
const parts = values.map((value) => condition({ field, op, value }))
|
||||
if (parts.length === 0) return ''
|
||||
if (parts.length === 1) return parts[0]
|
||||
return `(${parts.join('|')})`
|
||||
}
|
||||
|
||||
/** Assemble the URL query string ("?...") from a GridifyQuery. */
|
||||
export function toQueryString(q: GridifyQuery): string {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
@@ -47,11 +47,6 @@ export default function AppShell() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="app-logo" aria-hidden="true"><GerbilIcon size="1.5rem" /></span>
|
||||
<h1 className="app-title">{de.app.title}</h1>
|
||||
</header>
|
||||
|
||||
<nav className="app-nav" aria-label={de.nav.mainNavigation}>
|
||||
<div className="nav-primary">
|
||||
{PRIMARY.map((item) => (
|
||||
|
||||
271
gerbil-manager-web/src/components/GerbilFilterBar.tsx
Normal file
271
gerbil-manager-web/src/components/GerbilFilterBar.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* DESIGN (Rennmaus Filter.html): mobile-first filter UX —
|
||||
* 1. a horizontal "Quick-Chips" pill row (one pill per category, with a count badge),
|
||||
* 2. tapping a pill opens a bottom-sheet of multi-select options (✓ checkboxes,
|
||||
* searchable for long lists like Farbschlag / Herkunft),
|
||||
* 3. active selections appear ABOVE the list as removable chips (so they scroll
|
||||
* away with the list), with "Alle löschen".
|
||||
*
|
||||
* The user landed on the Quick-Chips concept, dezent style, brown accent. Colors
|
||||
* come from the app's existing tokens. `GerbilFilterBar` is the pill row + sheet;
|
||||
* `ActiveFilterChips` renders the removable chips and is placed above the list.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { de } from '../strings/de'
|
||||
import './gerbilFilterBar.css'
|
||||
|
||||
export interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Optional swatch dot (used for status colors). */
|
||||
color?: string
|
||||
/** Optional leading symbol (used for gender ♂ ♀ ?). */
|
||||
symbol?: string
|
||||
}
|
||||
|
||||
export interface FilterCategory {
|
||||
/** Matches the Gridify field / the key into the selected map. */
|
||||
key: string
|
||||
label: string
|
||||
/** Renders a search box above the option list (for long lists). */
|
||||
searchable?: boolean
|
||||
options: FilterOption[]
|
||||
}
|
||||
|
||||
function countFor(selected: Record<string, string[]>, key: string): number {
|
||||
return (selected[key] ?? []).length
|
||||
}
|
||||
|
||||
interface GerbilFilterBarProps {
|
||||
categories: FilterCategory[]
|
||||
selected: Record<string, string[]>
|
||||
onToggle: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
/** The Quick-Chips pill row + the category bottom-sheet. */
|
||||
export function GerbilFilterBar({ categories, selected, onToggle }: GerbilFilterBarProps) {
|
||||
const t = de.filterBar
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null)
|
||||
const activeCat = categories.find((c) => c.key === activeKey) ?? null
|
||||
|
||||
return (
|
||||
<div className="rmf">
|
||||
<div className="rmf-quickrow" role="group" aria-label={t.title}>
|
||||
{categories.map((cat) => {
|
||||
const n = countFor(selected, cat.key)
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
type="button"
|
||||
className={'rmf-pill' + (n > 0 ? ' is-on' : '')}
|
||||
onClick={() => setActiveKey(cat.key)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{cat.label}
|
||||
{n > 0 && <span className="rmf-pill-count">{n}</span>}
|
||||
<span className="rmf-pill-chev" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeCat && (
|
||||
<FilterSheet
|
||||
cat={activeCat}
|
||||
selected={selected[activeCat.key] ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={() => setActiveKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActiveFilterChipsProps {
|
||||
categories: FilterCategory[]
|
||||
selected: Record<string, string[]>
|
||||
onRemove: (key: string, value: string) => void
|
||||
onClearAll: () => void
|
||||
/** Optional per-chip accent colour (e.g. status colours); falls back to the app accent. */
|
||||
chipColor?: (key: string, value: string) => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Removable chips for the active selections, placed above the list so they scroll
|
||||
* away as you browse. Wraps onto multiple lines — nothing is hidden.
|
||||
*/
|
||||
export function ActiveFilterChips({
|
||||
categories,
|
||||
selected,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
chipColor,
|
||||
}: ActiveFilterChipsProps) {
|
||||
const t = de.filterBar
|
||||
|
||||
const chips = categories.flatMap((cat) => {
|
||||
const labelByValue = new Map(cat.options.map((o) => [o.value, o.label]))
|
||||
return (selected[cat.key] ?? []).map((value) => ({
|
||||
key: cat.key,
|
||||
value,
|
||||
catLabel: cat.label,
|
||||
label: labelByValue.get(value) ?? value,
|
||||
}))
|
||||
})
|
||||
|
||||
if (chips.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rmf-chips">
|
||||
{chips.map((c) => {
|
||||
const accent = chipColor?.(c.key, c.value)
|
||||
return (
|
||||
<button
|
||||
key={c.key + c.value}
|
||||
type="button"
|
||||
className="rmf-chip"
|
||||
style={accent ? ({ '--chip': accent } as CSSProperties) : undefined}
|
||||
onClick={() => onRemove(c.key, c.value)}
|
||||
aria-label={`${c.catLabel}: ${c.label} — ${t.remove}`}
|
||||
>
|
||||
<span className="rmf-chip-cat">{c.catLabel}</span>
|
||||
<span className="rmf-chip-val">{c.label}</span>
|
||||
<span className="rmf-chip-x" aria-hidden="true">×</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{chips.length > 1 && (
|
||||
<button type="button" className="rmf-chip rmf-chip-clear" onClick={onClearAll}>
|
||||
{t.clearAll}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BottomSheetProps {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable bottom-sheet: dimmed scrim + slide-up panel with grip, title and a
|
||||
* "Fertig" button. Locks body scroll and closes on Escape / scrim tap. Used for
|
||||
* the category filters AND the sort selector (same drawer mechanic).
|
||||
*/
|
||||
export function BottomSheet({ title, onClose, children }: BottomSheetProps) {
|
||||
const t = de.filterBar
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Portal to <body> so the sheet escapes any sticky/stacking-context ancestor
|
||||
// (e.g. the sticky header) and renders above the bottom action bar.
|
||||
return createPortal(
|
||||
<div className="rmf-scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="rmf-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="rmf-grip" aria-hidden="true" />
|
||||
<div className="rmf-sheet-head">
|
||||
<span className="rmf-sheet-title">{title}</span>
|
||||
<button type="button" className="rmf-sheet-done" onClick={onClose}>
|
||||
{t.done}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rmf-sheet-body">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterSheetProps {
|
||||
cat: FilterCategory
|
||||
selected: string[]
|
||||
onToggle: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function FilterSheet({ cat, selected, onToggle, onClose }: FilterSheetProps) {
|
||||
return (
|
||||
<BottomSheet title={cat.label} onClose={onClose}>
|
||||
<OptionList cat={cat} selected={selected} onToggle={onToggle} />
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
interface OptionListProps {
|
||||
cat: FilterCategory
|
||||
selected: string[]
|
||||
onToggle: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
function OptionList({ cat, selected, onToggle }: OptionListProps) {
|
||||
const t = de.filterBar
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!cat.searchable) return cat.options
|
||||
const term = q.trim().toLowerCase()
|
||||
if (!term) return cat.options
|
||||
return cat.options.filter((o) => o.label.toLowerCase().includes(term))
|
||||
}, [cat, q])
|
||||
|
||||
return (
|
||||
<div className="rmf-picker">
|
||||
{cat.searchable && (
|
||||
<input
|
||||
className="rmf-search"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
autoFocus
|
||||
aria-label={t.searchPlaceholder}
|
||||
/>
|
||||
)}
|
||||
<div className={'rmf-optlist' + (cat.searchable ? ' rmf-optlist--scroll' : '')}>
|
||||
{options.length === 0 && <div className="rmf-empty">{t.noMatch}</div>}
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o.value)
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={'rmf-opt' + (on ? ' is-on' : '')}
|
||||
onClick={() => onToggle(cat.key, o.value)}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span className="rmf-opt-box" aria-hidden="true">
|
||||
{on ? '✓' : ''}
|
||||
</span>
|
||||
{o.color && <span className="rmf-opt-dot" style={{ background: o.color }} />}
|
||||
{o.symbol && (
|
||||
<span className="rmf-opt-sym" aria-hidden="true">
|
||||
{o.symbol}
|
||||
</span>
|
||||
)}
|
||||
<span className="rmf-opt-label">{o.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,39 @@
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.namegen-panel__gender {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.namegen-gender-group {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.namegen-gender-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 0.35rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.namegen-gender-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.namegen-gender-btn.is-active {
|
||||
background: var(--color-accent, #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.namegen-panel__usages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,9 +12,17 @@ interface NameSuggestPanelProps {
|
||||
|
||||
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||
|
||||
type GenderSelection = 'any' | 'female' | 'male'
|
||||
|
||||
/** Map the gerbil form's gender onto the generator's three-way choice. */
|
||||
function seedGender(gender: string): GenderSelection {
|
||||
return gender === 'female' || gender === 'male' ? gender : 'any'
|
||||
}
|
||||
|
||||
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||
const t = de.namegen
|
||||
const [letter, setLetter] = useState('')
|
||||
const [genderSel, setGenderSel] = useState<GenderSelection>(() => seedGender(gender))
|
||||
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyMissing, setKeyMissing] = useState(false)
|
||||
@@ -39,7 +47,7 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
|
||||
try {
|
||||
const result = await suggestNames({
|
||||
letter: letter.trim() || undefined,
|
||||
gender: gender || undefined,
|
||||
gender: genderSel === 'any' ? undefined : genderSel,
|
||||
usages: [...usages] as NamegenUsageCode[],
|
||||
count: 6,
|
||||
})
|
||||
@@ -78,6 +86,28 @@ export default function NameSuggestPanel({ gender, onPick, onClose }: NameSugges
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__gender">
|
||||
<span>{t.genderLabel}</span>
|
||||
<div className="namegen-gender-group" role="radiogroup" aria-label={t.genderLabel}>
|
||||
{([
|
||||
['any', t.genderAny],
|
||||
['female', t.genderFemale],
|
||||
['male', t.genderMale],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={genderSel === value}
|
||||
className={`namegen-gender-btn ${genderSel === value ? 'is-active' : ''}`}
|
||||
onClick={() => setGenderSel(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="namegen-panel__usages">
|
||||
<span>{t.usagesLabel}</span>
|
||||
<div className="namegen-usages-grid">
|
||||
|
||||
427
gerbil-manager-web/src/components/gerbilFilterBar.css
Normal file
427
gerbil-manager-web/src/components/gerbilFilterBar.css
Normal file
@@ -0,0 +1,427 @@
|
||||
/* DESIGN (Rennmaus Filter.html): Quick-Chips + Bottom-Sheet + entfernbare Chips.
|
||||
Mobile-first. Palette + font sizes are taken 1:1 from the design (the app tokens
|
||||
are now the design's creme/brown/terracotta), so this matches the prototype. */
|
||||
|
||||
.rmf {
|
||||
--rmf-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- Quick-Chips pill row ---------- */
|
||||
.rmf-quickrow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 4px;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.rmf-quickrow::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.rmf-pill {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
padding: 9px 13px;
|
||||
border-radius: 999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.12s var(--rmf-ease), border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.rmf-pill:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rmf-pill.is-on {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.rmf-pill-count {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rmf-pill-chev {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* ---------- Active filter chips ---------- */
|
||||
.rmf-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
.rmf-chip {
|
||||
--chip: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: color-mix(in srgb, var(--chip) 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--chip) 32%, transparent);
|
||||
color: color-mix(in srgb, var(--chip) 78%, #2b2723);
|
||||
padding: 6px 8px 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
animation: rmfChipIn 0.22s var(--rmf-ease);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.rmf-chip:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.rmf-chip-cat {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.rmf-chip-val {
|
||||
font-weight: 700;
|
||||
}
|
||||
.rmf-chip-x {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--chip) 22%, transparent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rmf-chip-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.rmf-chip-clear .rmf-chip-x {
|
||||
display: none;
|
||||
}
|
||||
@keyframes rmfChipIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Bottom-Sheet ---------- */
|
||||
.rmf-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(43, 33, 25, 0.42);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: rmfScrim 0.2s ease;
|
||||
}
|
||||
@keyframes rmfScrim {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.rmf-sheet {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 22px 22px 0 0;
|
||||
max-height: 82vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -8px 40px rgba(43, 33, 25, 0.25);
|
||||
animation: rmfSheetUp 0.32s var(--rmf-ease);
|
||||
}
|
||||
@keyframes rmfSheetUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.rmf-grip {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-text-faint);
|
||||
opacity: 0.5;
|
||||
margin: 9px auto 4px;
|
||||
}
|
||||
.rmf-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.rmf-sheet-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rmf-sheet-done {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rmf-sheet-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 1rem 1.5rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ---------- Option list (multi-select checkboxes) ---------- */
|
||||
.rmf-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rmf-search {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem 0.875rem;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
margin: 0.25rem 0 0.375rem;
|
||||
}
|
||||
.rmf-search:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.rmf-optlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.rmf-optlist--scroll {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rmf-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
padding: 0.75rem 0.375rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.rmf-opt:hover {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.rmf-opt.is-on {
|
||||
font-weight: 700;
|
||||
}
|
||||
.rmf-opt-box {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
transition: background 0.14s, border-color 0.14s;
|
||||
}
|
||||
.rmf-opt.is-on .rmf-opt-box {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.rmf-opt-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rmf-opt-sym {
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.rmf-opt-label {
|
||||
flex: 1;
|
||||
}
|
||||
.rmf-empty {
|
||||
padding: 1.1rem 0.375rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ---------- Search bar ---------- */
|
||||
.rmf-searchbar {
|
||||
margin-top: 0.75rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.rmf-search-ico {
|
||||
padding-left: 0.875rem;
|
||||
font-size: 19px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.rmf-searchfield {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
color: var(--color-text);
|
||||
padding: 0.875rem 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rmf-searchfield::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.rmf-search-clear {
|
||||
border: none;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-text-muted);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.625rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- Bar: Externe-Ahnen toggle + Sortieren — two equal-width chips ---------- */
|
||||
.rm-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
/* Shared pill: both chips are the SAME size (flex: 1 → equal width). */
|
||||
.rmf-bar-chip {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
|
||||
}
|
||||
.rmf-bar-chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
/* Externe-Ahnen active state. */
|
||||
.rmf-bar-chip.is-on {
|
||||
background: var(--color-accent-soft);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 32%, transparent);
|
||||
color: color-mix(in srgb, var(--color-accent) 78%, #2b2723);
|
||||
}
|
||||
.rmf-bar-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rmf-bar-chip-ico {
|
||||
color: var(--color-accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
.rmf-bar-chip-chev {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* Toggle icon circle on the Externe-Ahnen chip (+ → ✓). */
|
||||
.rmf-chip-toggle-ico {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: 800;
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-accent);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.rmf-bar-chip.is-on .rmf-chip-toggle-ico {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Desktop: the sheet becomes a centered modal rather than a bottom drawer. */
|
||||
@media (min-width: 768px) {
|
||||
.rmf-scrim {
|
||||
align-items: center;
|
||||
}
|
||||
.rmf-sheet {
|
||||
border-radius: 18px;
|
||||
max-width: 420px;
|
||||
max-height: 70vh;
|
||||
animation: rmfChipIn 0.2s var(--rmf-ease);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export interface AsyncState<T> {
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
function messageFor(err: unknown): string {
|
||||
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
|
||||
|
||||
116
gerbil-manager-web/src/hooks/useInfiniteList.ts
Normal file
116
gerbil-manager-web/src/hooks/useInfiniteList.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Server-paginated infinite list (no react-query).
|
||||
*
|
||||
* `loader(page)` fetches ONE page; pages are accumulated into `items`. The list
|
||||
* never loads everything up front — `loadMore()` (wired to a scroll sentinel)
|
||||
* advances exactly one page at a time until `hasMore` is false.
|
||||
*
|
||||
* Changing `resetKey` (e.g. the filter/sort/search signature) clears the list and
|
||||
* reloads from page 1. `reload()` re-fetches page 1 on demand (e.g. retry).
|
||||
*
|
||||
* Design mirrors useApi (and satisfies the strict react-hooks rules):
|
||||
* - the request signature lives in `req`; the query reset on `resetKey` change is
|
||||
* an in-render state adjustment (not a setState-in-effect), so no cascade.
|
||||
* - `loading` / `loadingMore` are DERIVED from resolved-vs-current request keys,
|
||||
* never set synchronously inside the fetch effect.
|
||||
* - the loader is read through a ref so the effect keys purely on the request id.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { messageFor } from './useApi'
|
||||
import type { Paged } from '../api/types'
|
||||
|
||||
export interface InfiniteListState<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
/** Initial / reset load (page 1) in flight. */
|
||||
loading: boolean
|
||||
/** A subsequent page is being appended. */
|
||||
loadingMore: boolean
|
||||
error: string | null
|
||||
hasMore: boolean
|
||||
/** Advance one page; the scroll sentinel calls this. */
|
||||
loadMore: () => void
|
||||
/** Re-fetch from page 1 (retry). */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
interface Req {
|
||||
key: string
|
||||
page: number
|
||||
nonce: number
|
||||
}
|
||||
|
||||
interface Resolved<T> {
|
||||
reqId: string
|
||||
items: T[]
|
||||
total: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useInfiniteList<T>(
|
||||
loader: (page: number) => Promise<Paged<T>>,
|
||||
resetKey: string,
|
||||
): InfiniteListState<T> {
|
||||
const loaderRef = useRef(loader)
|
||||
useEffect(() => {
|
||||
loaderRef.current = loader
|
||||
})
|
||||
|
||||
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
|
||||
// Adjust state during render when the query signature changes — the recommended
|
||||
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
|
||||
if (req.key !== resetKey) {
|
||||
setReq({ key: resetKey, page: 1, nonce: 0 })
|
||||
}
|
||||
|
||||
const queryId = `${req.nonce}:${req.key}`
|
||||
const reqId = `${queryId}#${req.page}`
|
||||
|
||||
const [resolved, setResolved] = useState<Resolved<T>>({
|
||||
reqId: '',
|
||||
items: [],
|
||||
total: 0,
|
||||
error: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const page = req.page
|
||||
const id = `${req.nonce}:${req.key}#${page}`
|
||||
loaderRef
|
||||
.current(page)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setResolved((prev) => ({
|
||||
reqId: id,
|
||||
// page 1 replaces (fresh query); later pages append.
|
||||
items: page === 1 ? res.items : [...prev.items, ...res.items],
|
||||
total: res.totalCount,
|
||||
error: null,
|
||||
}))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [req]) // req is a fresh object per change (key/page/nonce) → one fetch per change
|
||||
|
||||
// Resolved data only counts if it belongs to the current query signature.
|
||||
const resolvedQueryId = resolved.reqId.slice(0, resolved.reqId.lastIndexOf('#'))
|
||||
const sameQuery = resolved.reqId !== '' && resolvedQueryId === queryId
|
||||
const inFlight = resolved.reqId !== reqId
|
||||
|
||||
const items = sameQuery ? resolved.items : []
|
||||
const total = sameQuery ? resolved.total : 0
|
||||
const loading = !sameQuery || (inFlight && req.page === 1)
|
||||
const loadingMore = sameQuery && inFlight && req.page > 1
|
||||
const error = inFlight ? null : sameQuery ? resolved.error : null
|
||||
const hasMore = sameQuery && !inFlight && items.length < total
|
||||
|
||||
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), [])
|
||||
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
|
||||
|
||||
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
|
||||
}
|
||||
@@ -2,15 +2,20 @@
|
||||
Seitenleiste links ab 768px (Laptop). */
|
||||
|
||||
:root {
|
||||
--color-bg: #faf7f2;
|
||||
/* DESIGN (Rennmaus Filter): the app palette is the design's creme/brown/terracotta. */
|
||||
--color-bg: #f6efe2;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e5ddd0;
|
||||
--color-text: #3b3026;
|
||||
--color-text-muted: #8a7d6b;
|
||||
--color-accent: #b5651d;
|
||||
--color-accent-soft: #f6e7d7;
|
||||
--color-border: #e7dcc8;
|
||||
--color-text: #3d2e23;
|
||||
--color-text-muted: #8c7f6e;
|
||||
--color-text-faint: #b4a790;
|
||||
--color-accent: #a85f2e;
|
||||
--color-accent-soft: #f3e7da;
|
||||
/* DESIGN (Rennmaus Filter): the single soft elevation used on cards, pills,
|
||||
the search bar and buttons. */
|
||||
--shadow: 0 1px 2px rgba(61, 46, 35, 0.06), 0 2px 8px rgba(61, 46, 35, 0.05);
|
||||
|
||||
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Hanken Grotesk', system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
@@ -41,46 +46,41 @@ a {
|
||||
|
||||
/* --- Grundgerüst (Smartphone) --- */
|
||||
|
||||
/* DESIGN: the top "Rennmaus-Manager" title bar is removed; on mobile only the
|
||||
bottom action bar remains, on desktop the left sidebar.
|
||||
The shell is a fixed viewport box and ONLY the main area scrolls — so the bottom
|
||||
nav (mobile) and the sidebar (desktop) stay put, and the page header's
|
||||
position: sticky pins reliably to the top of the scrolling main area. */
|
||||
.app-shell {
|
||||
display: grid;
|
||||
min-height: 100dvh;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'main'
|
||||
'nav';
|
||||
}
|
||||
|
||||
.app-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-area: main;
|
||||
padding: 1rem;
|
||||
/* No top padding: the page's sticky header pins flush to the top with no gap
|
||||
(pages add their own top spacing via .page). */
|
||||
padding: 0 1rem 1.5rem;
|
||||
max-width: 60rem;
|
||||
width: 100%;
|
||||
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
|
||||
the page body scroll horizontally. The .genotype-table-scroll wrapper
|
||||
provides the per-table horizontal scroll. */
|
||||
/* MAIN is the scroll container: min-height:0 lets the 1fr grid row shrink below
|
||||
its content so it can scroll. overflow-anchor:none stops the browser from
|
||||
re-adjusting scroll when the sticky title collapses (no scroll "fight"). */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* Top spacing for page content (replaces the removed .app-main top padding). The
|
||||
Rennmäuse page opts out — its sticky header provides its own top padding. */
|
||||
.page {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
/* Tab-Leiste unten */
|
||||
@@ -92,6 +92,8 @@ a {
|
||||
border-top: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
/* Above the sticky page header + cards so it is never overlapped. */
|
||||
z-index: 30;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
@@ -122,10 +124,8 @@ a {
|
||||
@media (min-width: 768px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 14rem 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
'header header'
|
||||
'nav main';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: 'nav main';
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
@@ -136,6 +136,9 @@ a {
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 1rem 0.5rem;
|
||||
position: static;
|
||||
/* Sidebar stays fixed; scrolls internally only if the link list is taller
|
||||
than the viewport. */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@@ -156,7 +159,7 @@ a {
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 1.5rem 2rem;
|
||||
padding: 0 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,33 +1,247 @@
|
||||
/* FEAT-12a multi-select on the Tiere list — page-scoped. */
|
||||
|
||||
/* The sticky header supplies the top spacing, so opt out of the global .page top
|
||||
padding (otherwise the bar wouldn't pin flush to the top). */
|
||||
.gerbils-page {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.gerbil-row {
|
||||
/* DESIGN (Rennmaus Filter): cards ease in (fade + slight rise) instead of popping
|
||||
when the result set changes. React keys by id, so only newly-appearing rows
|
||||
replay this; rows that persist across a filter change are reused and stay put. */
|
||||
animation: gerbilRowIn 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/* Light cascade so a fresh result set sweeps in rather than landing all at once.
|
||||
Capped at the first handful of rows; the rest share the last delay. */
|
||||
.gerbil-row:nth-child(1) { animation-delay: 0s; }
|
||||
.gerbil-row:nth-child(2) { animation-delay: 0.03s; }
|
||||
.gerbil-row:nth-child(3) { animation-delay: 0.06s; }
|
||||
.gerbil-row:nth-child(4) { animation-delay: 0.09s; }
|
||||
.gerbil-row:nth-child(5) { animation-delay: 0.12s; }
|
||||
.gerbil-row:nth-child(6) { animation-delay: 0.15s; }
|
||||
.gerbil-row:nth-child(n + 7) { animation-delay: 0.18s; }
|
||||
|
||||
@keyframes gerbilRowIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gerbil-row {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rm-noresults {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.gerbil-row__check {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.gerbil-row > .gerbil-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
/* Consistent breathing room below the sticky header: between the header and the
|
||||
list (no active filters) and between the active chips and the first card. */
|
||||
.gerbils-page .card-list {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
/* Infinite scroll: invisible sentinel observed to trigger the next page,
|
||||
plus a centered "loading more" line while a page is being appended. */
|
||||
.rmf-sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
.rmf-loadmore {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
/* ---------- DESIGN (Rennmaus Filter): sticky header that blends out on scroll ----------
|
||||
The whole header (title + search + filter pills + bar) sticks to the top; the list
|
||||
scrolls underneath. The title row collapses once a 1px sentinel above the header
|
||||
leaves the viewport. Negative margins let the bar span the app-main padding so its
|
||||
background/border reach edge-to-edge while pinned. */
|
||||
.gerbils-scrolltop {
|
||||
height: 1px;
|
||||
}
|
||||
.gerbils-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
z-index: 10;
|
||||
background: var(--color-bg);
|
||||
/* Horizontal negative margins let the bar's background + border-bottom span the
|
||||
app-main padding edge-to-edge while pinned. The body scrolls, so the app-main
|
||||
top padding scrolls away and the bar pins flush to the viewport top (no gap). */
|
||||
margin: 0 -1rem;
|
||||
padding: 0.5rem 1rem 0.625rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.gerbils-topbar {
|
||||
margin: 0 -2rem;
|
||||
padding: 0.75rem 2rem 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gerbils-head h2 {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.gerbils-head .muted {
|
||||
font-size: 14px;
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.gerbils-head {
|
||||
overflow: hidden;
|
||||
/* Generous cap so the expanded state never clips when the "Neu" button wraps on
|
||||
narrow phones; the collapse still animates cleanly from this height to 0. */
|
||||
max-height: 200px;
|
||||
transition: max-height 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease,
|
||||
margin 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.gerbils-head--condensed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gerbils-head {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- DESIGN (Rennmaus Filter): new two-row list card ----------
|
||||
Name + gender circle + status badge on top, "Farbschlag · Herkunft · geb. Datum"
|
||||
below. Overrides the former single-row grid card from index.css. */
|
||||
.gerbil-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Override the old single-row card's `align-items: center` (index.css) — in a
|
||||
column flex that centers children horizontally and shoved the name off the left. */
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 18px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s,
|
||||
transform 0.12s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.gerbil-card:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
|
||||
box-shadow: 0 2px 6px rgba(61, 46, 35, 0.1), 0 8px 22px rgba(61, 46, 35, 0.08);
|
||||
}
|
||||
.gerbil-card:active {
|
||||
transform: scale(0.992);
|
||||
}
|
||||
|
||||
.gerbil-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.gerbil-card__name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.gerbil-card__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gerbil-card__meta {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.gerbil-card__meta-strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.gerbil-card__dot {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* Status badge: design size (12px/700), coloured per status below. */
|
||||
.gerbil-card .badge {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Gender circle (♂/♀/?) — colored disc, from the design's GeschlechtIcon. */
|
||||
.gerbil-sex {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gerbil-sex--male {
|
||||
color: #5a7da8;
|
||||
background: color-mix(in srgb, #5a7da8 12%, #fff);
|
||||
}
|
||||
.gerbil-sex--female {
|
||||
color: #c2703d;
|
||||
background: color-mix(in srgb, #c2703d 12%, #fff);
|
||||
}
|
||||
.gerbil-sex--unknown {
|
||||
color: #9a938a;
|
||||
background: color-mix(in srgb, #9a938a 14%, #fff);
|
||||
}
|
||||
|
||||
/* Status badge coloured per status — scoped to cards so other pages stay as-is. */
|
||||
.gerbil-card .badge--breeding {
|
||||
color: #5e8c5a;
|
||||
background: color-mix(in srgb, #5e8c5a 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, #5e8c5a 30%, transparent);
|
||||
}
|
||||
.gerbil-card .badge--pet {
|
||||
color: #5a7da8;
|
||||
background: color-mix(in srgb, #5a7da8 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, #5a7da8 30%, transparent);
|
||||
}
|
||||
.gerbil-card .badge--forsale {
|
||||
color: #c2703d;
|
||||
background: color-mix(in srgb, #c2703d 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, #c2703d 30%, transparent);
|
||||
}
|
||||
.gerbil-card .badge--deceased {
|
||||
color: #8a847b;
|
||||
background: color-mix(in srgb, #8a847b 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, #8a847b 30%, transparent);
|
||||
}
|
||||
.gerbil-card .badge--givenaway {
|
||||
color: #b08e5c;
|
||||
background: color-mix(in srgb, #b08e5c 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, #b08e5c 32%, transparent);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export const de = {
|
||||
empty: 'Keine Rennmäuse gefunden.',
|
||||
countLabel: 'Tiere',
|
||||
searchPlaceholder: 'Name suchen …',
|
||||
// DESIGN (Rennmaus Filter): „geb." Präfix vor dem Geburtsdatum auf der Karte.
|
||||
born: 'geb.',
|
||||
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
|
||||
externalBadge: 'Extern',
|
||||
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
|
||||
@@ -58,6 +60,8 @@ export const de = {
|
||||
sortBy: 'Sortieren nach',
|
||||
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
|
||||
showExternal: 'Externe Ahnen einblenden',
|
||||
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
|
||||
showExternalShort: 'Externe Ahnen',
|
||||
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
|
||||
},
|
||||
// Sortier-Optionen
|
||||
@@ -872,12 +876,26 @@ export const de = {
|
||||
resetButton: 'Filter zurücksetzen',
|
||||
closeButton: 'Schließen',
|
||||
},
|
||||
// ── DESIGN (Rennmaus Filter): Quick-Chips-Filter mit Bottom-Sheet ──
|
||||
filterBar: {
|
||||
title: 'Filter',
|
||||
done: 'Fertig',
|
||||
clearAll: 'Alle löschen',
|
||||
remove: 'entfernen',
|
||||
searchPlaceholder: 'Suchen …',
|
||||
noMatch: 'Kein Treffer',
|
||||
searchClear: 'Suche leeren',
|
||||
},
|
||||
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
|
||||
namegen: {
|
||||
button: 'Name vorschlagen',
|
||||
panelTitle: 'Namensvorschläge',
|
||||
letterLabel: 'Anfangsbuchstabe',
|
||||
letterPlaceholder: 'z. B. A',
|
||||
genderLabel: 'Geschlecht',
|
||||
genderAny: 'Egal',
|
||||
genderFemale: 'Weiblich',
|
||||
genderMale: 'Männlich',
|
||||
usagesLabel: 'Herkunftskultur',
|
||||
loadButton: 'Vorschläge laden',
|
||||
loading: 'Lade Vorschläge …',
|
||||
|
||||
Reference in New Issue
Block a user