Design pass: contrast, shared form controls, dialogs, mobile layouts

A - accessibility
- on-accent-warm text token (For Sale badge, flags) and darker Sunset/Ocean
  colors to meet WCAG AA contrast
- 44px touch targets for Herd and Shows filters on phones
- status badge on non-active Herd grid cards; drop redundant View Herd

B - consistency
- FormControls (Field, FieldGroup, Input, Select, Textarea, FileInput,
  Segmented) used by every form, with labels wired to their inputs
- ConfirmDialog + useConfirm replace window.confirm and hand-built modals
- ErrorPanel with Try Again for failed lists; LoadError built on it

C - layout
- ActionMenu: rabbit header is Edit, Status, More
- Herd folds Status/Sort/view behind a Filters button on phones
- Dashboard status counts as a compact second row
- Shows list as stacked cards below xl
- SVG timeline icons instead of emoji

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-31 22:45:34 -04:00
parent 5dcaf9d04b
commit 3542739786
32 changed files with 979 additions and 591 deletions

View File

@ -126,4 +126,18 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
- **Settings** is split into a shared **Rabbitry** card (name, kit prefix, default breed) and a per-user **Your Preferences** card (weight display, theme), each saving only its own keys. Export is labelled **Back Up** and states that Import can't read it (Import = CSV/JSON herd replacement). - **Settings** is split into a shared **Rabbitry** card (name, kit prefix, default breed) and a per-user **Your Preferences** card (weight display, theme), each saving only its own keys. Export is labelled **Back Up** and states that Import can't read it (Import = CSV/JSON herd replacement).
- **Genetics is hidden from the sidebar and mobile More menu** until the calculator is built; the `/genetics` placeholder route still exists. Grandparents are corrected by editing the parent rabbit (Edit Rabbit only re-links sire/dam), by user decision. - **Genetics is hidden from the sidebar and mobile More menu** until the calculator is built; the `/genetics` placeholder route still exists. Grandparents are corrected by editing the parent rabbit (Edit Rabbit only re-links sire/dam), by user decision.
- Herd's sex tab for `sex='unknown'` is labelled **Unknown** (was "Other", which collided with the `other` status). - Herd's sex tab for `sex='unknown'` is labelled **Unknown** (was "Other", which collided with the `other` status).
- **Design pass A (2026-10-31)** — accessibility fixes:
- Text on `accent-warm` fills uses the **`on-accent-warm`** token (dark ink in every theme; Midnight maps it to `surface`), never `text-surface` — white on the warm oranges fails contrast. Sunset's accent/hover and secondary text and Ocean's secondary text were darkened to reach WCAG AA (≥4.5:1).
- Filter controls on Herd and Shows are ≥44px tall on phones (`min-h-[44px] md:min-h-[32px]`, matching `Button size="sm"`).
- Herd grid cards badge any non-active status over the photo; the Dashboard's redundant "View Herd" quick action was removed.
- **Design pass B (2026-10-31)** — shared UI primitives; use these instead of hand-styling:
- **`components/FormControls.jsx`**: `Field` (label + hint/error, wires `htmlFor`/`aria-describedby`/`aria-invalid` to its control via context), `FieldGroup` (same label style for non-input controls such as `RabbitAutocomplete` or `Segmented`), `Input` / `Select` / `Textarea` / `FileInput` (`size="md"` forms: 44px touch, 40px desktop; `size="sm"` filter bars: 44px touch, 32px desktop), and `Segmented` (button-group choice: Sex, Weight Display). `BreedSelect` renders an `Input`, so it picks up a surrounding `Field`'s id. Every form in the app — including the login/setup screens — uses these; the only raw inputs left are search boxes with icons and the pedigree-tree slot editors.
- **`components/ConfirmDialog.jsx`** (native `<dialog>` + `showModal()`: focus trap, Escape, top layer; focus starts on Cancel) replaces every `window.confirm` and the hand-built Settings import / remove-user modals. `hooks/useConfirm.jsx` is the promise-based `await confirm({...})` form; render `<ConfirmDialog>` directly when the dialog must show progress (`busy`/`busyLabel`).
- **`components/ErrorPanel.jsx`** is the failed-to-load state for lists/sections (title, error message, Try Again → React Query `refetch`); `LoadError` (whole-page record) is built on it. Show Event Detail now uses `LoadError`.
- **Design pass C (2026-10-31)** — layout:
- **`components/ActionMenu.jsx`**: a "More ▾" button + menu for secondary page actions (`items` = `{label, to}` / `{label, onClick}`). Rabbit Detail's header is Edit · Status · More (Record Breeding / Record Litter for bucks & does, Print Pedigree); Delete stays at the page bottom.
- Herd on phones shows the sex tabs + search; Status, Sort and grid/list fold behind a **Filters** button (badged with how many differ from the defaults). Desktop shows everything inline.
- Dashboard: Herd / Bucks / Does / Kits are the large tiles; For Sale / On Hold / Sold / Archived are a compact second row (same `--stat-N` theme vars).
- Shows: stacked `ShowListCard`s below `xl`; the 9-column table only at `xl`+ where it fits beside the sidebar.
- Rabbit timeline markers are inline SVG stroke icons (no emoji anywhere in the client).
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner. - **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.

View File

@ -8,6 +8,8 @@ import {
setUnauthorizedHandler, setUnauthorizedHandler,
} from './api' } from './api'
import { AuthContext } from './authContext' import { AuthContext } from './authContext'
import Button from './components/Button'
import { Field, Input } from './components/FormControls'
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
@ -29,28 +31,19 @@ function AuthShell({ title, subtitle, onSubmit, error, children }) {
) )
} }
function Field({ label, hint, ...inputProps }) { function AuthField({ label, hint, ...inputProps }) {
return ( return (
<div> <Field label={label} hint={hint}>
<label className="block font-body text-sm font-medium text-text-primary">{label}</label> <Input {...inputProps} />
<input </Field>
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
{...inputProps}
/>
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
</div>
) )
} }
function SubmitButton({ disabled, children }) { function SubmitButton({ disabled, children }) {
return ( return (
<button <Button type="submit" disabled={disabled} className="w-full">
type="submit"
disabled={disabled}
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
>
{children} {children}
</button> </Button>
) )
} }
@ -76,7 +69,7 @@ function LoginScreen({ onSuccess }) {
return ( return (
<AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}> <AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}>
<Field <AuthField
label="Username" label="Username"
type="text" type="text"
autoFocus autoFocus
@ -84,7 +77,7 @@ function LoginScreen({ onSuccess }) {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<Field <AuthField
label="Password" label="Password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
@ -136,7 +129,7 @@ function SetupScreen({ onSuccess }) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
error={error} error={error}
> >
<Field <AuthField
label="Username" label="Username"
type="text" type="text"
autoFocus autoFocus
@ -144,7 +137,7 @@ function SetupScreen({ onSuccess }) {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<Field <AuthField
label="Password" label="Password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
@ -152,7 +145,7 @@ function SetupScreen({ onSuccess }) {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<Field <AuthField
label="Confirm password" label="Confirm password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"

View File

@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import Button from './Button'
const ITEM_CLASS =
'flex min-h-[44px] w-full items-center rounded px-3 text-left font-body text-sm text-text-primary hover:bg-surface-sunken md:min-h-[36px]'
// A "More" button that opens a short list of secondary actions, so a page
// header can keep its primary actions visible without wrapping into several
// rows on a phone. `items` is [{ label, to } | { label, onClick }].
function ActionMenu({ label = 'More', items, size = 'sm' }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
if (!open) return
function onPointerDown(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
function onKeyDown(e) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
return (
<div ref={ref} className="relative">
<Button
type="button"
variant="secondary"
size={size}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
>
{label}
<svg viewBox="0 0 20 20" className="ml-1 h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="m6 8 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
{open && (
<div
role="menu"
className="absolute right-0 z-30 mt-1 w-52 rounded-lg border border-border bg-surface-raised p-1 shadow-lg"
>
{items.map((item) =>
item.to ? (
<Link key={item.label} to={item.to} role="menuitem" className={ITEM_CLASS}>
{item.label}
</Link>
) : (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setOpen(false)
item.onClick()
}}
className={ITEM_CLASS}
>
{item.label}
</button>
)
)}
</div>
)}
</div>
)
}
export default ActionMenu

View File

@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getShowEvents, createShowEvent, addShowToEvent } from '../api' import { getShowEvents, createShowEvent, addShowToEvent } from '../api'
import Button from './Button' import Button from './Button'
import { Field, Input, Select, Textarea } from './FormControls'
// Modal launched from a row's "Add to Event" button in the Shows browser. // Modal launched from a row's "Add to Event" button in the Shows browser.
// Lets the user drop the ARBA show into an existing event or spin up a new one // Lets the user drop the ARBA show into an existing event or spin up a new one
@ -102,44 +103,29 @@ function AddToEventModal({ show, onClose }) {
)} )}
{effectiveMode === 'existing' ? ( {effectiveMode === 'existing' ? (
<div> <Field label="Event">
<label className="block font-body text-sm font-medium text-text-primary">Event</label> <Select value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
className="mt-1 w-full rounded-md border border-border bg-surface-raised px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">{isLoading ? 'Loading…' : 'Select an event'}</option> <option value="">{isLoading ? 'Loading…' : 'Select an event'}</option>
{events?.map((event) => ( {events?.map((event) => (
<option key={event.id} value={event.id}> <option key={event.id} value={event.id}>
{event.name} ({event.show_count} show{event.show_count === 1 ? '' : 's'}) {event.name} ({event.show_count} show{event.show_count === 1 ? '' : 's'})
</option> </option>
))} ))}
</select> </Select>
</div> </Field>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <Field label="Event name" required>
<label className="block font-body text-sm font-medium text-text-primary"> <Input
Event name <span className="text-danger">*</span>
</label>
<input
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Spring Circuit Weekend" placeholder="e.g. Spring Circuit Weekend"
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </Field>
<div> <Field label="Notes">
<label className="block font-body text-sm font-medium text-text-primary">Notes</label> <Textarea value={newNotes} onChange={(e) => setNewNotes(e.target.value)} rows={2} />
<textarea </Field>
value={newNotes}
onChange={(e) => setNewNotes(e.target.value)}
rows={2}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div> </div>
)} )}

View File

@ -2,7 +2,7 @@ import { STATUS_LABELS } from '../lib/format'
const STATUS_CLASSES = { const STATUS_CLASSES = {
active: 'bg-accent text-surface', active: 'bg-accent text-surface',
for_sale: 'bg-accent-warm text-surface', for_sale: 'bg-accent-warm text-on-accent-warm',
on_hold: 'bg-text-secondary text-surface', on_hold: 'bg-text-secondary text-surface',
sold: 'bg-border text-text-primary', sold: 'bg-border text-text-primary',
deceased: 'bg-danger text-surface', deceased: 'bg-danger text-surface',

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Input } from './FormControls'
import { ARBA_BREEDS, isCustomBreed } from '../lib/breeds' import { ARBA_BREEDS, isCustomBreed } from '../lib/breeds'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav' import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
@ -39,7 +40,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={value} value={value}
@ -53,9 +54,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
autoComplete="off" autoComplete="off"
className={`mt-1 w-full rounded-md border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent ${ aria-invalid={invalid || undefined}
invalid ? 'border-danger' : 'border-border'
}`}
/> />
{isCustomBreed(value) && ( {isCustomBreed(value) && (
@ -63,7 +62,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
)} )}
{open && ( {open && (
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised p-1 shadow-sm"> <div className="absolute left-0 top-full z-30 mt-1 w-full rounded-md border border-border bg-surface-raised p-1 shadow-sm">
<ul className="max-h-56 overflow-auto"> <ul className="max-h-56 overflow-auto">
{matches.map((breed, index) => ( {matches.map((breed, index) => (
<li key={breed}> <li key={breed}>

View File

@ -0,0 +1,56 @@
import { useEffect, useId, useRef } from 'react'
import Button from './Button'
// The app's one "are you sure?" dialog, used in place of window.confirm so
// confirmations match the theme and read the same everywhere. Mount it only
// while it should show. It's a native <dialog> opened with showModal(), which
// gives focus trapping, Escape-to-cancel and top-layer stacking (nothing can
// render over it) for free. Focus starts on Cancel, the safe choice.
function ConfirmDialog({
title,
children,
confirmLabel = 'Confirm',
busyLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}) {
const ref = useRef(null)
const titleId = useId()
useEffect(() => {
const dialog = ref.current
if (!dialog.open) dialog.showModal()
return () => dialog.close()
}, [])
return (
<dialog
ref={ref}
aria-labelledby={titleId}
onCancel={(e) => {
e.preventDefault()
if (!busy) onCancel()
}}
className="w-[calc(100%-2rem)] max-w-md rounded-lg border border-border bg-surface-raised p-0 text-text-primary shadow-lg backdrop:bg-black/40"
>
<div className="space-y-4 p-6">
<h2 id={titleId} className="font-display text-lg text-text-primary">
{title}
</h2>
{children && <div className="space-y-2 font-body text-sm text-text-secondary">{children}</div>}
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} disabled={busy}>
{busy && busyLabel ? busyLabel : confirmLabel}
</Button>
</div>
</div>
</dialog>
)
}
export default ConfirmDialog

View File

@ -0,0 +1,26 @@
import Button from './Button'
import Card from './Card'
import EmptyState from './EmptyState'
// The app's failed-to-load state for a list or section: what failed, why if
// known, and a way to retry (usually a React Query `refetch`).
function ErrorPanel({ title, error, onRetry, action }) {
return (
<Card>
<EmptyState
title={title}
description={error?.message || 'Check your connection and try again.'}
action={
action ??
(onRetry ? (
<Button variant="secondary" onClick={() => onRetry()}>
Try Again
</Button>
) : null)
}
/>
</Card>
)
}
export default ErrorPanel

View File

@ -0,0 +1,136 @@
import { createContext, useContext, useId } from 'react'
// Shared form controls, so every text box / dropdown in the app has the same
// height, border, focus ring and error styling.
//
// <Field> renders the label, optional hint and error, and hands its generated
// id (plus aria-describedby / aria-invalid) to the one control inside it via
// context — so labels are always wired to their inputs without threading ids
// by hand. A control used outside a <Field> just needs an aria-label.
const FieldContext = createContext(null)
// md: the standard form size (44px touch / 40px desktop, like <Button>).
// sm: filter bars and other dense rows (44px touch / 32px desktop).
const SIZE_CLASSES = {
md: 'min-h-[44px] px-3 py-2 md:min-h-[40px]',
sm: 'min-h-[44px] px-3 py-1.5 md:min-h-[32px]',
multiline: 'px-3 py-2',
}
function controlClasses({ size = 'md', invalid, className = '' }) {
return `rounded-md border bg-surface-raised font-body text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50 ${
invalid ? 'border-danger' : 'border-border'
} ${SIZE_CLASSES[size]} ${className}`
}
function useFieldProps(props) {
const field = useContext(FieldContext)
const invalid = props['aria-invalid'] ?? (field?.invalid || undefined)
return {
id: props.id ?? field?.id,
'aria-describedby': props['aria-describedby'] ?? field?.describedBy,
'aria-invalid': invalid,
invalid: Boolean(invalid),
}
}
export function Input({ size, className = 'w-full', ...props }) {
const { invalid, ...aria } = useFieldProps(props)
return <input {...props} {...aria} className={controlClasses({ size, invalid, className })} />
}
export function Select({ size, className = 'w-full', ...props }) {
const { invalid, ...aria } = useFieldProps(props)
return <select {...props} {...aria} className={controlClasses({ size, invalid, className })} />
}
export function Textarea({ className = 'w-full', rows = 3, ...props }) {
const { invalid, ...aria } = useFieldProps(props)
return (
<textarea
rows={rows}
{...props}
{...aria}
className={controlClasses({ size: 'multiline', invalid, className })}
/>
)
}
// File pickers are styled through the browser's file: button rather than the
// text-box look.
export function FileInput(props) {
const { id, 'aria-describedby': describedBy } = useFieldProps(props)
return (
<input
type="file"
{...props}
id={id}
aria-describedby={describedBy}
className="block w-full font-body text-sm text-text-secondary file:mr-3 file:min-h-[40px] file:cursor-pointer file:rounded-md file:border file:border-solid file:border-border file:bg-surface-raised file:px-3 file:py-2 file:font-body file:text-sm file:font-medium file:text-text-primary hover:file:bg-surface-sunken"
/>
)
}
// A row of mutually exclusive buttons (Sex, Weight Display) — quicker than a
// dropdown for two to four short choices. `options` is [{ value, label }].
export function Segmented({ options, value, onChange, label }) {
return (
<div className="flex gap-1" role="group" aria-label={label}>
{options.map((opt) => (
<button
key={opt.value}
type="button"
aria-pressed={value === opt.value}
onClick={() => onChange(opt.value)}
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-[40px] ${
value === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
)
}
// A label over a control that isn't a single input (a Segmented row, a
// picker): same heading style as <Field>, without the label/for wiring.
export function FieldGroup({ label, hint, className = '', children }) {
return (
<div className={className}>
<span className="block font-body text-sm font-medium text-text-primary">{label}</span>
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
<div className={hint ? 'mt-2' : 'mt-1'}>{children}</div>
</div>
)
}
// `label` may be a node (e.g. a required star); `hint` sits under the control
// in secondary text and is replaced by `error` while there is one.
export function Field({ label, hint, error, required, className = '', children }) {
const id = useId()
const messageId = `${id}-message`
const message = error || hint
return (
<div className={className}>
<label htmlFor={id} className="block font-body text-sm font-medium text-text-primary">
{label}
{required && <span className="text-danger"> *</span>}
</label>
<div className="mt-1">
<FieldContext.Provider
value={{ id, describedBy: message ? messageId : undefined, invalid: Boolean(error) }}
>
{children}
</FieldContext.Provider>
</div>
{message && (
<p id={messageId} className={`mt-1 font-body text-xs ${error ? 'text-danger' : 'text-text-secondary'}`}>
{message}
</p>
)}
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import RabbitAutocomplete from './RabbitAutocomplete' import RabbitAutocomplete from './RabbitAutocomplete'
import Button from './Button' import Button from './Button'
import { Field, FieldGroup, Input, Textarea } from './FormControls'
// Shared by AddLitter/EditLitter (kindle records) and AddBreeding (breeding // Shared by AddLitter/EditLitter (kindle records) and AddBreeding (breeding
// records). A litter row spans the breeding→kindle lifecycle, so both a breeding // records). A litter row spans the breeding→kindle lifecycle, so both a breeding
@ -51,87 +52,58 @@ function LitterForm({
return ( return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4"> <form onSubmit={handleSubmit} className="max-w-md space-y-4">
<div> <FieldGroup label="Sire">
<label className="block font-body text-xs text-text-secondary">Sire</label> <RabbitAutocomplete sex="buck" value={sire} onChange={setSire} />
<div className="mt-0.5"> </FieldGroup>
<RabbitAutocomplete sex="buck" value={sire} onChange={setSire} />
</div>
</div>
<div> <FieldGroup label="Dam">
<label className="block font-body text-xs text-text-secondary">Dam</label> <RabbitAutocomplete sex="doe" value={dam} onChange={setDam} />
<div className="mt-0.5"> </FieldGroup>
<RabbitAutocomplete sex="doe" value={dam} onChange={setDam} />
</div>
</div>
<div> <Field
<label className="block font-body text-xs text-text-secondary"> label="Breeding Date"
{isBreeding ? 'Breeding Date *' : 'Breeding Date'} required={isBreeding}
</label> error={isBreeding && showValidation ? 'Breeding date is required' : null}
<input >
<Input
type="date" type="date"
value={breedDate} value={breedDate}
onChange={(e) => { onChange={(e) => {
setBreedDate(e.target.value) setBreedDate(e.target.value)
setShowValidation(false) setShowValidation(false)
}} }}
className={`mt-0.5 w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
isBreeding && showValidation ? 'border-danger' : 'border-border'
}`}
/> />
{isBreeding && showValidation && ( </Field>
<p className="mt-1 font-body text-xs text-danger">Breeding date is required</p>
)}
</div>
{!isBreeding && ( {!isBreeding && (
<> <>
<div> <Field label="Kindle Date" required error={showValidation ? 'Kindle date is required' : null}>
<label className="block font-body text-xs text-text-secondary">Kindle Date *</label> <Input
<input
type="date" type="date"
value={kindleDate} value={kindleDate}
onChange={(e) => { onChange={(e) => {
setKindleDate(e.target.value) setKindleDate(e.target.value)
setShowValidation(false) setShowValidation(false)
}} }}
className={`mt-0.5 w-full rounded border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
showValidation ? 'border-danger' : 'border-border'
}`}
/> />
{showValidation && ( </Field>
<p className="mt-1 font-body text-xs text-danger">Kindle date is required</p>
)}
</div>
<div> <Field
<label className="block font-body text-xs text-text-secondary">Kit Count</label> label="Kit Count"
<input hint={
type="number" createsKits
min="0"
value={kitCount}
onChange={(e) => setKitCount(e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="mt-1 font-body text-xs text-text-secondary">
{createsKits
? 'Saving creates this many kits in your herd, named with the Kit Prefix from Settings. Leave blank to add kits one at a time.' ? 'Saving creates this many kits in your herd, named with the Kit Prefix from Settings. Leave blank to add kits one at a time.'
: 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'} : 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'
</p> }
</div> >
<Input type="number" min="0" value={kitCount} onChange={(e) => setKitCount(e.target.value)} />
</Field>
</> </>
)} )}
<div> <Field label="Notes">
<label className="block font-body text-xs text-text-secondary">Notes</label> <Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
<textarea </Field>
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="mt-0.5 w-full rounded border border-border px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{error && <p className="font-body text-sm text-danger">{error}</p>} {error && <p className="font-body text-sm text-danger">{error}</p>}

View File

@ -1,6 +1,5 @@
import Button from './Button' import Button from './Button'
import Card from './Card' import ErrorPanel from './ErrorPanel'
import EmptyState from './EmptyState'
import PageHeader from './PageHeader' import PageHeader from './PageHeader'
// The failed-fetch state for a page whose main record didn't load — most often // The failed-fetch state for a page whose main record didn't load — most often
@ -11,17 +10,11 @@ function LoadError({ title, error, backTo, backLabel }) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader title={title} /> <PageHeader title={title} />
<Card> <ErrorPanel
<EmptyState title={notFound ? `${title} not found` : `Couldn’t load ${title.toLowerCase()}`}
title={notFound ? `${title} not found` : `Couldn’t load ${title.toLowerCase()}`} error={notFound ? { message: 'It may have been deleted, or the link is out of date.' } : error}
description={ action={backTo ? <Button to={backTo}>{backLabel}</Button> : null}
notFound />
? 'It may have been deleted, or the link is out of date.'
: error?.message || 'Check your connection and try again.'
}
action={backTo ? <Button to={backTo}>{backLabel}</Button> : null}
/>
</Card>
</div> </div>
) )
} }

View File

@ -52,7 +52,7 @@ function PedigreeScan({ result, imageUrl, onScanned, confirmReplace }) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
e.target.value = '' e.target.value = ''
if (!file) return if (!file) return
if (confirmReplace && !confirmReplace()) return if (confirmReplace && !(await confirmReplace())) return
setScanning(true) setScanning(true)
setError(null) setError(null)

View File

@ -201,7 +201,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<span className="flex w-full items-center gap-1.5 font-body text-xs text-text-secondary"> <span className="flex w-full items-center gap-1.5 font-body text-xs text-text-secondary">
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
{flagged && ( {flagged && (
<span className="shrink-0 rounded bg-accent-warm px-1.5 font-medium text-surface">Check</span> <span className="shrink-0 rounded bg-accent-warm px-1.5 font-medium text-on-accent-warm">Check</span>
)} )}
</span> </span>
<span <span

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import Badge from './Badge'
import Card from './Card' import Card from './Card'
import Silhouette from './Silhouette' import Silhouette from './Silhouette'
import { SERVER_BASE } from '../api' import { SERVER_BASE } from '../api'
@ -14,7 +15,7 @@ function RabbitCard({ rabbit }) {
return ( return (
<Link to={`/herd/${rabbit.id}`}> <Link to={`/herd/${rabbit.id}`}>
<Card className="flex h-full flex-col overflow-hidden transition-colors hover:bg-surface-sunken"> <Card className="flex h-full flex-col overflow-hidden transition-colors hover:bg-surface-sunken">
<div className="flex aspect-square items-center justify-center bg-surface-sunken"> <div className="relative flex aspect-square items-center justify-center bg-surface-sunken">
{rabbit.photo_path ? ( {rabbit.photo_path ? (
<img <img
src={`${SERVER_BASE}${rabbit.photo_path}`} src={`${SERVER_BASE}${rabbit.photo_path}`}
@ -24,6 +25,13 @@ function RabbitCard({ rabbit }) {
) : ( ) : (
<Silhouette /> <Silhouette />
)} )}
{/* Active is the norm, so only the exceptions (For Sale, Sold, …) are
badged — enough to tell them apart when the Herd filter is "All". */}
{rabbit.status !== 'active' && (
<span className="absolute left-2 top-2">
<Badge status={rabbit.status} />
</span>
)}
</div> </div>
<div className="flex flex-col gap-1 p-3"> <div className="flex flex-col gap-1 p-3">
<p className="truncate font-display text-base text-text-primary">{rabbit.name}</p> <p className="truncate font-display text-base text-text-primary">{rabbit.name}</p>

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import BreedSelect from './BreedSelect' import BreedSelect from './BreedSelect'
import { Field, FieldGroup, FileInput, Input, Segmented, Select } from './FormControls'
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' }, { value: 'active', label: 'Active' },
@ -16,10 +17,6 @@ const SEX_OPTIONS = [
{ value: 'unknown', label: 'Unknown' }, { value: 'unknown', label: 'Unknown' },
] ]
const INPUT_CLASS =
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
const LABEL_CLASS = 'block font-body text-sm font-medium text-text-primary'
function lbsToOz(decimalLbs) { function lbsToOz(decimalLbs) {
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' } if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
const total = Number(decimalLbs) const total = Number(decimalLbs)
@ -73,100 +70,58 @@ function RabbitDetailsForm({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2"> <Field
<label htmlFor={nameInputId} className={LABEL_CLASS}> label="Name"
Name <span className="text-danger">*</span> required
</label> error={nameInvalid ? 'Give this rabbit a name to continue.' : null}
<input className="md:col-span-2"
>
<Input
id={nameInputId} id={nameInputId}
type="text" type="text"
value={value.name ?? ''} value={value.name ?? ''}
onChange={(e) => setField('name', e.target.value)} onChange={(e) => setField('name', e.target.value)}
aria-invalid={nameInvalid || undefined}
aria-describedby={nameInvalid ? `${nameInputId}-error` : undefined}
className={`mt-1 w-full rounded-md border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent ${
nameInvalid ? 'border-danger' : 'border-border'
}`}
/> />
{nameInvalid && ( </Field>
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
Give this rabbit a name to continue.
</p>
)}
</div>
<div> <Field label="Farm / Prefix">
<label className={LABEL_CLASS}>Farm / Prefix</label> <Input type="text" value={value.farm ?? ''} onChange={(e) => setField('farm', e.target.value)} />
<input </Field>
type="text"
value={value.farm ?? ''}
onChange={(e) => setField('farm', e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div> <Field label="Ear Number">
<label className={LABEL_CLASS}>Ear Number</label> <Input
<input
type="text" type="text"
value={value.ear_number ?? ''} value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())} onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
className={`${INPUT_CLASS} uppercase`} className="w-full uppercase"
/> />
</div> </Field>
<div> <FieldGroup label="Sex">
<span className={LABEL_CLASS}>Sex</span> <Segmented label="Sex" options={SEX_OPTIONS} value={value.sex} onChange={(sex) => setField('sex', sex)} />
<div className="mt-1 flex gap-1" role="group" aria-label="Sex"> </FieldGroup>
{SEX_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
aria-pressed={value.sex === opt.value}
onClick={() => setField('sex', opt.value)}
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-0 ${
value.sex === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div> <Field label="Date of Birth">
<label className={LABEL_CLASS}>Date of Birth</label> <Input type="date" value={value.dob ?? ''} onChange={(e) => setField('dob', e.target.value)} />
<input </Field>
type="date"
value={value.dob ?? ''}
onChange={(e) => setField('dob', e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div> <Field label="Breed">
<label className={LABEL_CLASS}>Breed</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} /> <BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
</div> </Field>
<div> <Field label="Color Name">
<label className={LABEL_CLASS}>Color Name</label> <Input
<input
type="text" type="text"
value={value.color_name ?? ''} value={value.color_name ?? ''}
onChange={(e) => setField('color_name', e.target.value)} onChange={(e) => setField('color_name', e.target.value)}
placeholder="e.g. Broken Black Tort" placeholder="e.g. Broken Black Tort"
className={INPUT_CLASS}
/> />
</div> </Field>
<div> {weightUnit === 'lbs_oz' ? (
<label className={LABEL_CLASS}>Weight</label> <FieldGroup label="Weight">
{weightUnit === 'lbs_oz' ? ( <div className="flex gap-2">
<div className="mt-1 flex gap-2"> <Input
<input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min="0" min="0"
@ -174,9 +129,9 @@ function RabbitDetailsForm({
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))} onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
placeholder="lbs" placeholder="lbs"
aria-label="Weight, pounds" aria-label="Weight, pounds"
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-1/2"
/> />
<input <Input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min="0" min="0"
@ -185,11 +140,13 @@ function RabbitDetailsForm({
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))} onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
placeholder="oz" placeholder="oz"
aria-label="Weight, ounces" aria-label="Weight, ounces"
className="w-1/2 rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-1/2"
/> />
</div> </div>
) : ( </FieldGroup>
<input ) : (
<Field label="Weight">
<Input
type="number" type="number"
inputMode="decimal" inputMode="decimal"
min="0" min="0"
@ -197,23 +154,16 @@ function RabbitDetailsForm({
value={value.weight_lbs ?? ''} value={value.weight_lbs ?? ''}
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))} onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
placeholder="decimal lbs" placeholder="decimal lbs"
className={INPUT_CLASS}
/> />
)} </Field>
</div> )}
<div className="md:col-span-2"> <Field label="Photo" className="md:col-span-2">
<label className={LABEL_CLASS}>Photo</label> <FileInput accept="image/jpeg,image/png,image/webp" onChange={handlePhotoSelect} />
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoSelect}
className="mt-1 block w-full font-body text-sm text-text-secondary file:mr-3 file:min-h-[40px] file:cursor-pointer file:rounded-md file:border file:border-solid file:border-border file:bg-surface-raised file:px-3 file:py-2 file:font-body file:text-sm file:font-medium file:text-text-primary hover:file:bg-surface-sunken"
/>
{photoPreview && ( {photoPreview && (
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" /> <img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
)} )}
</div> </Field>
</div> </div>
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}> <div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
@ -222,7 +172,7 @@ function RabbitDetailsForm({
type="button" type="button"
onClick={() => setMoreOpen((v) => !v)} onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen} aria-expanded={moreOpen}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left font-body text-sm font-medium text-text-primary" className="flex min-h-[44px] w-full items-center justify-between gap-3 px-4 py-3 text-left font-body text-sm font-medium text-text-primary"
> >
<span>More details</span> <span>More details</span>
<span className="font-normal text-text-secondary">Status, location, dates</span> <span className="font-normal text-text-secondary">Status, location, dates</span>
@ -235,52 +185,40 @@ function RabbitDetailsForm({
collapseSecondary ? 'border-t border-border px-4 py-4' : '' collapseSecondary ? 'border-t border-border px-4 py-4' : ''
}`} }`}
> >
<div> <Field label="Status">
<label className={LABEL_CLASS}>Status</label> <Select value={value.status ?? 'active'} onChange={(e) => setField('status', e.target.value)}>
<select
value={value.status ?? 'active'}
onChange={(e) => setField('status', e.target.value)}
className={`${INPUT_CLASS} bg-surface-raised`}
>
{STATUS_OPTIONS.map((opt) => ( {STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</option> </option>
))} ))}
</select> </Select>
</div> </Field>
<div> <Field label="Location">
<label className={LABEL_CLASS}>Location</label> <Input
<input
type="text" type="text"
value={value.location ?? ''} value={value.location ?? ''}
onChange={(e) => setField('location', e.target.value)} onChange={(e) => setField('location', e.target.value)}
placeholder="Cage or hutch" placeholder="Cage or hutch"
className={INPUT_CLASS}
/> />
</div> </Field>
<div> <Field label="Acquired Date" hint="If added to the herd after birth">
<label className={LABEL_CLASS}>Acquired Date</label> <Input
<input
type="date" type="date"
value={value.acquired_date ?? ''} value={value.acquired_date ?? ''}
onChange={(e) => setField('acquired_date', e.target.value || null)} onChange={(e) => setField('acquired_date', e.target.value || null)}
className={INPUT_CLASS}
/> />
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p> </Field>
</div>
<div> <Field label="Death Date">
<label className={LABEL_CLASS}>Death Date</label> <Input
<input
type="date" type="date"
value={value.death_date ?? ''} value={value.death_date ?? ''}
onChange={(e) => setField('death_date', e.target.value || null)} onChange={(e) => setField('death_date', e.target.value || null)}
className={INPUT_CLASS}
/> />
</div> </Field>
</div> </div>
)} )}
</div> </div>

View File

@ -2,21 +2,68 @@ import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import Button from './Button' import Button from './Button'
import { Field, Input, Select, Textarea } from './FormControls'
import { getLitters, getEvents, getRabbitShowEntries, createEvent, updateEvent, deleteEvent } from '../api' import { getLitters, getEvents, getRabbitShowEntries, createEvent, updateEvent, deleteEvent } from '../api'
import { formatDate } from '../lib/format' import { formatDate } from '../lib/format'
import { useConfirm } from '../hooks/useConfirm'
// Visual config per timeline event type. `order` breaks ties when two events // Visual config per timeline event type. `order` breaks ties when two events
// share a date (e.g. birth always sorts above an acquired-on-the-same-day note). // share a date (e.g. birth always sorts above an acquired-on-the-same-day note).
// `dot` is a Tailwind class pair for the rail marker. // `dot` is a Tailwind class pair for the rail marker. Icons are stroke paths
// on a 24px grid in the nav-icon style of Layout.jsx (emoji render differently
// on every device).
const EVENT_TYPES = { const EVENT_TYPES = {
birth: { icon: '🐣', order: 0, dot: 'bg-accent text-surface' }, birth: {
acquired: { icon: '🏡', order: 1, dot: 'bg-accent-warm text-surface' }, icon: <path d="M12 3c3.5 0 6 5 6 10a6 6 0 0 1-12 0c0-5 2.5-10 6-10Z" />,
breeding: { icon: '❤', order: 2, dot: 'bg-surface-sunken text-text-primary' }, order: 0,
kindle: { icon: '🐰', order: 3, dot: 'bg-accent text-surface' }, dot: 'bg-accent text-surface',
show: { icon: '🏆', order: 4, dot: 'bg-accent-warm text-surface' }, },
sale: { icon: '🏷️', order: 5, dot: 'bg-accent text-surface' }, acquired: {
other: { icon: '📌', order: 6, dot: 'bg-surface-sunken text-text-primary' }, icon: <path d="M4 11.5 12 4l8 7.5M6.5 10v10h11V10" />,
death: { icon: '✝', order: 7, dot: 'bg-danger text-white' }, order: 1,
dot: 'bg-accent-warm text-on-accent-warm',
},
breeding: {
icon: <path d="M12 20s-7.5-4.6-7.5-10.2A4.2 4.2 0 0 1 12 7.4a4.2 4.2 0 0 1 7.5 2.4C19.5 15.4 12 20 12 20Z" />,
order: 2,
dot: 'bg-surface-sunken text-text-primary',
},
kindle: {
icon: <path d="M4 11h16v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7ZM8 7.5C8 6.1 9.8 5 12 5s4 1.1 4 2.5" />,
order: 3,
dot: 'bg-accent text-surface',
},
show: {
icon: (
<>
<circle cx="12" cy="9" r="5.5" />
<path d="m9 14-1.5 7 4.5-2.5 4.5 2.5L15 14" />
</>
),
order: 4,
dot: 'bg-accent-warm text-on-accent-warm',
},
sale: {
icon: (
<>
<path d="M3.5 12.5V4h8.5l9 9-8.5 8.5-9-9Z" />
<circle cx="8" cy="8.5" r="1" />
</>
),
order: 5,
dot: 'bg-accent text-surface',
},
other: {
icon: (
<>
<path d="M12 21s-6.5-6-6.5-11a6.5 6.5 0 0 1 13 0c0 5-6.5 11-6.5 11Z" />
<circle cx="12" cy="10" r="2" />
</>
),
order: 6,
dot: 'bg-surface-sunken text-text-primary',
},
death: { icon: <path d="M12 3v18M7 8.5h10" />, order: 7, dot: 'bg-danger text-white' },
} }
// The manually-loggable event types (the Add Event dropdown). Breeding is left // The manually-loggable event types (the Add Event dropdown). Breeding is left
@ -129,9 +176,6 @@ function buildEvents(rabbit, litters, showEntries, logged) {
}) })
} }
const inputClass =
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
function EventForm({ editingEvent, onSave, onCancel, saving }) { function EventForm({ editingEvent, onSave, onCancel, saving }) {
const [form, setForm] = useState( const [form, setForm] = useState(
editingEvent editingEvent
@ -160,81 +204,54 @@ function EventForm({ editingEvent, onSave, onCancel, saving }) {
Edit {ADD_EVENT_TYPES.find((t) => t.value === form.type)?.label ?? 'Event'} Edit {ADD_EVENT_TYPES.find((t) => t.value === form.type)?.label ?? 'Event'}
</p> </p>
) : ( ) : (
<div> <Field label="Event Type">
<label className="block font-body text-sm font-medium text-text-primary">Event Type</label> <Select value={form.type} onChange={(e) => setField('type', e.target.value)}>
<select
value={form.type}
onChange={(e) => setField('type', e.target.value)}
className={`${inputClass} bg-surface-raised`}
>
{ADD_EVENT_TYPES.map((t) => ( {ADD_EVENT_TYPES.map((t) => (
<option key={t.value} value={t.value}> <option key={t.value} value={t.value}>
{t.label} {t.label}
</option> </option>
))} ))}
</select> </Select>
</div> </Field>
)} )}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <Field label="Date" required>
<label className="block font-body text-sm font-medium text-text-primary"> <Input type="date" value={form.event_date} onChange={(e) => setField('event_date', e.target.value)} />
Date <span className="text-danger">*</span> </Field>
</label> <Field label={TITLE_LABEL[form.type]} required>
<input <Input
type="date"
value={form.event_date}
onChange={(e) => setField('event_date', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">
{TITLE_LABEL[form.type]} <span className="text-danger">*</span>
</label>
<input
type="text" type="text"
value={form.title} value={form.title}
onChange={(e) => setField('title', e.target.value)} onChange={(e) => setField('title', e.target.value)}
placeholder={TITLE_PLACEHOLDER[form.type]} placeholder={TITLE_PLACEHOLDER[form.type]}
className={inputClass}
/> />
</div> </Field>
{isShow && ( {isShow && (
<> <>
<div> <Field label="Class">
<label className="block font-body text-sm font-medium text-text-primary">Class</label> <Input
<input
type="text" type="text"
value={form.show_class} value={form.show_class}
onChange={(e) => setField('show_class', e.target.value)} onChange={(e) => setField('show_class', e.target.value)}
placeholder="e.g. Senior Buck" placeholder="e.g. Senior Buck"
className={inputClass}
/> />
</div> </Field>
<div> <Field label="Placement">
<label className="block font-body text-sm font-medium text-text-primary">Placement</label> <Input
<input
type="text" type="text"
value={form.placement} value={form.placement}
onChange={(e) => setField('placement', e.target.value)} onChange={(e) => setField('placement', e.target.value)}
placeholder="e.g. BOB, 1st" placeholder="e.g. BOB, 1st"
className={inputClass}
/> />
</div> </Field>
</> </>
)} )}
</div> </div>
<div> <Field label="Notes">
<label className="block font-body text-sm font-medium text-text-primary">Notes</label> <Textarea value={form.notes} onChange={(e) => setField('notes', e.target.value)} rows={2} />
<textarea </Field>
value={form.notes}
onChange={(e) => setField('notes', e.target.value)}
rows={2}
className={inputClass}
/>
</div>
{isShow && !editingEvent && ( {isShow && !editingEvent && (
<p className="font-body text-xs text-text-secondary"> <p className="font-body text-xs text-text-secondary">
@ -281,9 +298,20 @@ function TimelineRow({ event, isLast, onEdit, onDelete }) {
<li className="grid grid-cols-[1.5rem_1fr] gap-x-3"> <li className="grid grid-cols-[1.5rem_1fr] gap-x-3">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span <span
className={`z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[0.7rem] ${config.dot}`} className={`z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${config.dot}`}
> >
{config.icon} <svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{config.icon}
</svg>
</span> </span>
{!isLast && <span className="w-px flex-1 bg-border" />} {!isLast && <span className="w-px flex-1 bg-border" />}
</div> </div>
@ -320,6 +348,7 @@ function RabbitTimeline({ rabbit }) {
const [formMode, setFormMode] = useState(null) // null | 'add' | event id being edited const [formMode, setFormMode] = useState(null) // null | 'add' | event id being edited
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [confirm, confirmDialog] = useConfirm()
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters }) const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
const { data: logged } = useQuery({ const { data: logged } = useQuery({
@ -362,7 +391,8 @@ function RabbitTimeline({ rabbit }) {
} }
async function handleDelete(ev) { async function handleDelete(ev) {
if (!window.confirm(`Delete the "${ev.title}" event?`)) return const confirmed = await confirm({ title: `Delete “${ev.title}”?`, confirmLabel: 'Delete', danger: true })
if (!confirmed) return
setError(null) setError(null)
try { try {
await deleteEvent(ev.id) await deleteEvent(ev.id)
@ -422,6 +452,7 @@ function RabbitTimeline({ rabbit }) {
))} ))}
</ol> </ol>
)} )}
{confirmDialog}
</div> </div>
) )
} }

View File

@ -2,14 +2,13 @@ import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
import ConfirmDialog from './ConfirmDialog'
import { Field, Input } from './FormControls'
import { getUsers, createUser, resetUserPassword, deleteUser } from '../api' import { getUsers, createUser, resetUserPassword, deleteUser } from '../api'
import { useAuth } from '../authContext' import { useAuth } from '../authContext'
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
const inputClass =
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
// Settings → Users (memory-bank/FEATURE_User_Accounts.md): everyone-equal user // Settings → Users (memory-bank/FEATURE_User_Accounts.md): everyone-equal user
// management for the shared herd. Any signed-in user can add users, reset any // management for the shared herd. Any signed-in user can add users, reset any
// password (that's the "someone forgot theirs" path — no current-password // password (that's the "someone forgot theirs" path — no current-password
@ -116,19 +115,15 @@ export default function UsersCard({ onToast }) {
</div> </div>
{resetTarget === user.id && ( {resetTarget === user.id && (
<div className="mt-2 flex items-end gap-2"> <div className="mt-2 flex items-end gap-2">
<div className="flex-1"> <Field label={`New password (min ${MIN_PASSWORD_LENGTH} characters)`} className="flex-1">
<label className="block font-body text-xs text-text-secondary"> <Input
New password (min {MIN_PASSWORD_LENGTH} characters)
</label>
<input
type="password" type="password"
autoFocus autoFocus
autoComplete="new-password" autoComplete="new-password"
value={resetPassword} value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)} onChange={(e) => setResetPassword(e.target.value)}
className={inputClass}
/> />
</div> </Field>
<Button <Button
onClick={() => handleReset(user)} onClick={() => handleReset(user)}
disabled={busy || resetPassword.length < MIN_PASSWORD_LENGTH} disabled={busy || resetPassword.length < MIN_PASSWORD_LENGTH}
@ -143,30 +138,24 @@ export default function UsersCard({ onToast }) {
)} )}
<form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4"> <form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4">
<h3 className="font-body text-sm font-medium text-text-primary">Add a user</h3> <h3 className="font-display text-base text-text-primary">Add a user</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <Field label="Username">
<label className="block font-body text-xs text-text-secondary">Username</label> <Input
<input
type="text" type="text"
autoComplete="off" autoComplete="off"
value={newUsername} value={newUsername}
onChange={(e) => setNewUsername(e.target.value)} onChange={(e) => setNewUsername(e.target.value)}
className={inputClass}
/> />
</div> </Field>
<div> <Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters`}>
<label className="block font-body text-xs text-text-secondary"> <Input
Password (min {MIN_PASSWORD_LENGTH} characters)
</label>
<input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
/> />
</div> </Field>
</div> </div>
{addError && <p className="font-body text-sm text-danger">{addError}</p>} {addError && <p className="font-body text-sm text-danger">{addError}</p>}
<Button <Button
@ -178,25 +167,18 @@ export default function UsersCard({ onToast }) {
</form> </form>
{deleteTarget && ( {deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <ConfirmDialog
<Card className="w-full max-w-md space-y-4 p-6"> title={`Remove ${deleteTarget.username}?`}
<h2 className="font-display text-lg text-text-primary"> confirmLabel="Remove User"
Remove {deleteTarget.username}? busyLabel="Removing…"
</h2> danger
<p className="font-body text-sm text-text-secondary"> busy={busy}
They will be signed out everywhere and can no longer log in. The herd data is shared, onConfirm={handleDelete}
so nothing else is deleted. onCancel={() => setDeleteTarget(null)}
</p> >
<div className="flex justify-end gap-2"> They will be signed out everywhere and can no longer log in. The herd data is shared, so nothing else is
<Button variant="secondary" onClick={() => setDeleteTarget(null)} disabled={busy}> deleted.
Cancel </ConfirmDialog>
</Button>
<Button variant="danger" onClick={handleDelete} disabled={busy}>
{busy ? 'Removing…' : 'Remove User'}
</Button>
</div>
</Card>
</div>
)} )}
</Card> </Card>
) )

View File

@ -0,0 +1,35 @@
import { useState } from 'react'
import ConfirmDialog from '../components/ConfirmDialog'
// Promise-based drop-in for window.confirm:
// const [confirm, confirmDialog] = useConfirm()
// if (!(await confirm({ title: 'Delete it?', confirmLabel: 'Delete', danger: true }))) return
// and render {confirmDialog} somewhere in the component. For a confirmation
// that also shows progress while the action runs, render <ConfirmDialog>
// directly instead.
export function useConfirm() {
const [request, setRequest] = useState(null)
function confirm(options) {
return new Promise((resolve) => setRequest({ ...options, resolve }))
}
function settle(result) {
request.resolve(result)
setRequest(null)
}
const dialog = request ? (
<ConfirmDialog
title={request.title}
confirmLabel={request.confirmLabel}
danger={request.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
>
{request.message}
</ConfirmDialog>
) : null
return [confirm, dialog]
}

View File

@ -31,6 +31,11 @@
`bg-[var(--stat-N)]` classes (Tailwind only generates classes it can see as `bg-[var(--stat-N)]` classes (Tailwind only generates classes it can see as
complete strings, so the per-tile names are spelled out, not built up). */ complete strings, so the per-tile names are spelled out, not built up). */
:root { :root {
/* Text on accent-warm fills (the For Sale badge, "Check" flags). The warm
oranges are too light for white text in the light themes, so this is the
theme's dark ink everywhere — on Midnight that's the surface color. */
--color-on-accent-warm: var(--color-text-primary);
--stat-fg: var(--color-text-primary); --stat-fg: var(--color-text-primary);
--stat-fg-muted: var(--color-text-secondary); --stat-fg-muted: var(--color-text-secondary);
--stat-1: var(--color-surface-raised); --stat-1: var(--color-surface-raised);
@ -64,6 +69,7 @@
--color-accent-hover: #94b884; --color-accent-hover: #94b884;
--color-accent-warm: #d9954a; --color-accent-warm: #d9954a;
--color-danger: #d46a6a; --color-danger: #d46a6a;
--color-on-accent-warm: var(--color-surface);
} }
[data-theme='ocean'] { [data-theme='ocean'] {
@ -72,7 +78,7 @@
--color-surface-sunken: #e1eaf0; --color-surface-sunken: #e1eaf0;
--color-border: #c2d3de; --color-border: #c2d3de;
--color-text-primary: #14222b; --color-text-primary: #14222b;
--color-text-secondary: #5a7484; --color-text-secondary: #516a79;
--color-accent: #2e6e8e; --color-accent: #2e6e8e;
--color-accent-hover: #245870; --color-accent-hover: #245870;
--color-accent-warm: #e0913b; --color-accent-warm: #e0913b;
@ -98,9 +104,9 @@
--color-surface-sunken: #f4e6da; --color-surface-sunken: #f4e6da;
--color-border: #e6cdb8; --color-border: #e6cdb8;
--color-text-primary: #2a1c14; --color-text-primary: #2a1c14;
--color-text-secondary: #8a6f5c; --color-text-secondary: #7a604d;
--color-accent: #c9542e; --color-accent: #b84a26;
--color-accent-hover: #a8431f; --color-accent-hover: #9a3d1c;
--color-accent-warm: #e0913b; --color-accent-warm: #e0913b;
--color-danger: #b33a3a; --color-danger: #b33a3a;
} }

View File

@ -48,8 +48,8 @@ export const THEMES = [
id: 'sunset', id: 'sunset',
label: 'Sunset', label: 'Sunset',
description: 'Warm coral and amber.', description: 'Warm coral and amber.',
themeColor: '#c9542e', themeColor: '#b84a26',
swatch: { surface: '#fbf3ec', accent: '#c9542e', warm: '#e0913b' }, swatch: { surface: '#fbf3ec', accent: '#b84a26', warm: '#e0913b' },
}, },
{ {
id: 'carnival', id: 'carnival',

View File

@ -9,6 +9,7 @@ import ReviewStep from '../components/ReviewStep'
import StickyActionBar from '../components/StickyActionBar' import StickyActionBar from '../components/StickyActionBar'
import PedigreeScan from '../components/PedigreeScan' import PedigreeScan from '../components/PedigreeScan'
import Button from '../components/Button' import Button from '../components/Button'
import { Field, Input } from '../components/FormControls'
import { import {
getSettings, getSettings,
getRabbit, getRabbit,
@ -17,6 +18,7 @@ import {
getPedigreeScanStatus, getPedigreeScanStatus,
findExistingRabbit, findExistingRabbit,
} from '../api' } from '../api'
import { useConfirm } from '../hooks/useConfirm'
import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree' import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree'
const STEP_LABELS = ['Pedigree', 'Details', 'Review'] const STEP_LABELS = ['Pedigree', 'Details', 'Review']
@ -153,6 +155,7 @@ function AddRabbit() {
const [scanImageUrl, setScanImageUrl] = useState(null) const [scanImageUrl, setScanImageUrl] = useState(null)
const [pedigreeSeed, setPedigreeSeed] = useState(null) const [pedigreeSeed, setPedigreeSeed] = useState(null)
const [pedigreeKey, setPedigreeKey] = useState(0) const [pedigreeKey, setPedigreeKey] = useState(0)
const [confirm, confirmDialog] = useConfirm()
const step2Valid = details.name.trim().length > 0 const step2Valid = details.name.trim().length > 0
@ -266,7 +269,14 @@ function AddRabbit() {
// Scanning replaces the tree wholesale, so confirm before discarding entries. // Scanning replaces the tree wholesale, so confirm before discarding entries.
function confirmReplaceWithScan() { function confirmReplaceWithScan() {
const hasEntries = pedigreeSummary.some((node) => node.display && node.display !== 'Unknown') const hasEntries = pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
return !hasEntries || window.confirm('Replace the ancestors you’ve entered with the scanned pedigree?') return (
!hasEntries ||
confirm({
title: 'Replace your ancestors?',
message: 'The scanned pedigree will replace the ancestors you’ve entered so far.',
confirmLabel: 'Replace',
})
)
} }
function goToStep(n) { function goToStep(n) {
@ -339,19 +349,15 @@ function AddRabbit() {
(or jumping from Review via "Edit") keeps the pedigree tree's (or jumping from Review via "Edit") keeps the pedigree tree's
internal state, the photo preview, and any open sections intact. */} internal state, the photo preview, and any open sections intact. */}
<div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}> <div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
<div> <Field label="Rabbit name">
<label htmlFor="add-rabbit-subject-name" className="block font-body text-sm font-medium text-text-primary"> <Input
Rabbit name
</label>
<input
id="add-rabbit-subject-name"
type="text" type="text"
value={details.name} value={details.name}
onChange={(e) => updateDetails({ name: e.target.value })} onChange={(e) => updateDetails({ name: e.target.value })}
placeholder="You can also fill this in on the next step" placeholder="You can also fill this in on the next step"
className="mt-1 w-full max-w-md rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full max-w-md"
/> />
</div> </Field>
<p className="font-body text-sm text-text-secondary"> <p className="font-body text-sm text-text-secondary">
Start with the parents. Tap any ancestor to search your herd, enter them manually, or mark them unknown. Start with the parents. Tap any ancestor to search your herd, enter them manually, or mark them unknown.
Picking a rabbit from your herd fills in its ancestors automatically. Picking a rabbit from your herd fills in its ancestors automatically.
@ -426,6 +432,7 @@ function AddRabbit() {
</Button> </Button>
)} )}
</StickyActionBar> </StickyActionBar>
{confirmDialog}
</div> </div>
) )
} }

View File

@ -4,6 +4,7 @@ import { getStats, getLitters, getRabbits, getSettings } from '../api'
import Card from '../components/Card' import Card from '../components/Card'
import Button from '../components/Button' import Button from '../components/Button'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import ErrorPanel from '../components/ErrorPanel'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import { GESTATION_DAYS, addDays, formatDate } from '../lib/format' import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
@ -19,6 +20,8 @@ const STAT_CARDS = [
{ key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', tile: 'bg-[var(--stat-2)] hover:bg-[var(--stat-2-hover)]' }, { key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', tile: 'bg-[var(--stat-2)] hover:bg-[var(--stat-2-hover)]' },
{ key: 'does', label: 'Does', href: '/herd?sex=doe', tile: 'bg-[var(--stat-3)] hover:bg-[var(--stat-3-hover)]' }, { key: 'does', label: 'Does', href: '/herd?sex=doe', tile: 'bg-[var(--stat-3)] hover:bg-[var(--stat-3-hover)]' },
{ key: 'kits', label: 'Kits', href: '/herd?sex=kits', tile: 'bg-[var(--stat-4)] hover:bg-[var(--stat-4-hover)]' }, { key: 'kits', label: 'Kits', href: '/herd?sex=kits', tile: 'bg-[var(--stat-4)] hover:bg-[var(--stat-4-hover)]' },
// The rest are status counts — shown as a smaller second row so the herd
// makeup reads first.
{ key: 'for_sale', label: 'For Sale', href: '/herd?status=for_sale', tile: 'bg-[var(--stat-5)] hover:bg-[var(--stat-5-hover)]' }, { key: 'for_sale', label: 'For Sale', href: '/herd?status=for_sale', tile: 'bg-[var(--stat-5)] hover:bg-[var(--stat-5-hover)]' },
{ key: 'on_hold', label: 'On Hold', href: '/herd?status=on_hold', tile: 'bg-[var(--stat-6)] hover:bg-[var(--stat-6-hover)]' }, { key: 'on_hold', label: 'On Hold', href: '/herd?status=on_hold', tile: 'bg-[var(--stat-6)] hover:bg-[var(--stat-6-hover)]' },
{ key: 'sold', label: 'Sold', href: '/herd?status=sold', tile: 'bg-[var(--stat-7)] hover:bg-[var(--stat-7-hover)]' }, { key: 'sold', label: 'Sold', href: '/herd?status=sold', tile: 'bg-[var(--stat-7)] hover:bg-[var(--stat-7-hover)]' },
@ -26,39 +29,56 @@ const STAT_CARDS = [
] ]
function StatsBar() { function StatsBar() {
const { data, isLoading, isError } = useQuery({ queryKey: ['stats'], queryFn: getStats }) const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['stats'], queryFn: getStats })
if (isError) { if (isError) {
return ( return <ErrorPanel title="Couldn’t load herd counts" error={error} onRetry={refetch} />
<p className="font-body text-sm text-danger">Failed to load dashboard stats.</p>
)
} }
const primary = STAT_CARDS.slice(0, 4)
const secondary = STAT_CARDS.slice(4)
return ( return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="flex flex-col gap-3">
{STAT_CARDS.map((card) => <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
isLoading ? ( {primary.map((card) =>
<Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" /> isLoading ? (
) : ( <Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" />
<Link key={card.key} to={card.href}> ) : (
{/* Deliberately NOT <Card>: Card hardcodes bg-surface-raised, which <Link key={card.key} to={card.href}>
lands *after* the arbitrary-value bg-[var(--stat-N)] in the {/* Deliberately NOT <Card>: Card hardcodes bg-surface-raised, which
compiled CSS and overrides it — the carnival tile colors then lands *after* the arbitrary-value bg-[var(--stat-N)] in the
only showed on hover. The tile's own var already defaults to compiled CSS and overrides it — the carnival tile colors then
surface-raised, so plain themes look identical. */} only showed on hover. The tile's own var already defaults to
<div surface-raised, so plain themes look identical. */}
className={`rounded-lg border border-border shadow-sm flex flex-col gap-1 p-4 transition-colors ${card.tile}`} <div
> className={`rounded-lg border border-border shadow-sm flex flex-col gap-1 p-4 transition-colors ${card.tile}`}
<span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]"> >
{card.label} <span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]">
</span> {card.label}
<span className="font-display text-2xl text-[var(--stat-fg)]"> </span>
{data[card.key]} <span className="font-display text-2xl text-[var(--stat-fg)]">{data[card.key]}</span>
</span> </div>
</div> </Link>
</Link> )
) )}
)} </div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{secondary.map((card) =>
isLoading ? (
<Card key={card.key} className="h-11 animate-pulse bg-surface-sunken" />
) : (
<Link key={card.key} to={card.href}>
<div
className={`flex min-h-[44px] items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 shadow-sm transition-colors ${card.tile}`}
>
<span className="font-body text-sm text-[var(--stat-fg-muted)]">{card.label}</span>
<span className="font-display text-lg text-[var(--stat-fg)]">{data[card.key]}</span>
</div>
</Link>
)
)}
</div>
</div> </div>
) )
} }
@ -69,7 +89,6 @@ function QuickActions() {
<Button to="/herd/add" variant="primary">Add Rabbit</Button> <Button to="/herd/add" variant="primary">Add Rabbit</Button>
<Button to="/breedings/add" variant="secondary">Record Breeding</Button> <Button to="/breedings/add" variant="secondary">Record Breeding</Button>
<Button to="/litters/add" variant="secondary">Record Litter</Button> <Button to="/litters/add" variant="secondary">Record Litter</Button>
<Button to="/herd" variant="secondary">View Herd</Button>
</div> </div>
) )
} }

View File

@ -7,6 +7,7 @@ import GeneticsForm from '../components/GeneticsForm'
import RabbitAutocomplete from '../components/RabbitAutocomplete' import RabbitAutocomplete from '../components/RabbitAutocomplete'
import StickyActionBar from '../components/StickyActionBar' import StickyActionBar from '../components/StickyActionBar'
import LoadError from '../components/LoadError' import LoadError from '../components/LoadError'
import { FieldGroup } from '../components/FormControls'
import Button from '../components/Button' import Button from '../components/Button'
import { getRabbit, getRabbitAncestors, getSettings, updateRabbit, uploadRabbitPhoto } from '../api' import { getRabbit, getRabbitAncestors, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
import { LOCI, hasAnyLocusSet } from '../lib/genetics' import { LOCI, hasAnyLocusSet } from '../lib/genetics'
@ -45,17 +46,14 @@ function branchTextColumns(side) {
// record) is shown so the user knows what's there before replacing it. // record) is shown so the user knows what's there before replacing it.
function ParentField({ label, sex, value, onChange, manualName }) { function ParentField({ label, sex, value, onChange, manualName }) {
return ( return (
<div> <FieldGroup label={label}>
<span className="block font-body text-sm font-medium text-text-primary">{label}</span> <RabbitAutocomplete sex={sex} value={value} onChange={onChange} />
<div className="mt-1">
<RabbitAutocomplete sex={sex} value={value} onChange={onChange} />
</div>
{!value && manualName && ( {!value && manualName && (
<p className="mt-1 font-body text-xs text-text-secondary"> <p className="mt-1 font-body text-xs text-text-secondary">
Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record. Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record.
</p> </p>
)} )}
</div> </FieldGroup>
) )
} }
@ -174,7 +172,7 @@ function EditRabbit() {
/> />
<div className="rounded-lg border border-border px-4 py-4"> <div className="rounded-lg border border-border px-4 py-4">
<h2 className="font-body text-sm font-medium text-text-primary">Parents</h2> <h2 className="font-display text-lg text-text-primary">Parents</h2>
<p className="mt-1 font-body text-xs text-text-secondary"> <p className="mt-1 font-body text-xs text-text-secondary">
Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam. Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
</p> </p>

View File

@ -8,6 +8,8 @@ import EmptyState from '../components/EmptyState'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import Button from '../components/Button' import Button from '../components/Button'
import RabbitCard from '../components/RabbitCard' import RabbitCard from '../components/RabbitCard'
import { Select } from '../components/FormControls'
import ErrorPanel from '../components/ErrorPanel'
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format' import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit' import { useWeightUnit } from '../hooks/useWeightUnit'
@ -86,7 +88,7 @@ function SearchBox({ q, onSearchChange }) {
}, [searchInput]) }, [searchInput])
return ( return (
<div className="flex min-w-0 flex-1 basis-full items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 sm:basis-auto md:flex-none"> <div className="flex min-h-[44px] md:min-h-[32px] min-w-0 flex-1 items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 md:flex-none">
<SearchIcon /> <SearchIcon />
<input <input
type="text" type="text"
@ -100,7 +102,13 @@ function SearchBox({ q, onSearchChange }) {
) )
} }
// On phones only the sex tabs and search show by default; Status, Sort and the
// grid/list toggle fold behind a "Filters" button (badged with how many differ
// from the defaults) so the herd starts above the fold.
function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, onSortChange, onSearchChange, onViewChange }) { function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, onSortChange, onSearchChange, onViewChange }) {
const [filtersOpen, setFiltersOpen] = useState(false)
const changedCount = (status !== DEFAULT_STATUS ? 1 : 0) + (sort !== 'name' ? 1 : 0)
return ( return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-1 rounded-md border border-border bg-surface-raised p-1"> <div className="flex flex-wrap gap-1 rounded-md border border-border bg-surface-raised p-1">
@ -109,7 +117,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
key={tab.value} key={tab.value}
type="button" type="button"
onClick={() => onSexChange(tab.value)} onClick={() => onSexChange(tab.value)}
className={`rounded-md px-3 py-1.5 font-body text-sm transition-colors ${ className={`min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm transition-colors ${
sex === tab.value sex === tab.value
? 'bg-accent text-surface' ? 'bg-accent text-surface'
: 'text-text-primary hover:bg-surface-sunken' : 'text-text-primary hover:bg-surface-sunken'
@ -121,53 +129,68 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<SearchBox key={q} q={q} onSearchChange={onSearchChange} /> <div className="flex w-full gap-2 md:w-auto">
<SearchBox key={q} q={q} onSearchChange={onSearchChange} />
<select <Button
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
Status: {option.label}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
Sort: {option.label}
</option>
))}
</select>
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
<button
type="button" type="button"
aria-label="Grid view" variant="secondary"
onClick={() => onViewChange('grid')} size="sm"
className={`rounded-md p-1.5 transition-colors ${ onClick={() => setFiltersOpen((v) => !v)}
view === 'grid' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken' aria-expanded={filtersOpen}
}`} className="shrink-0 gap-1.5 md:hidden"
> >
<GridIcon /> Filters
</button> {changedCount > 0 && (
<button <span className="rounded-full bg-accent px-1.5 text-xs text-surface">{changedCount}</span>
type="button" )}
aria-label="List view" </Button>
onClick={() => onViewChange('list')} </div>
className={`rounded-md p-1.5 transition-colors ${
view === 'list' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken' <div className={`${filtersOpen ? 'flex' : 'hidden'} w-full flex-wrap items-center gap-3 md:flex md:w-auto`}>
}`} <Select
size="sm"
value={status}
onChange={(e) => onStatusChange(e.target.value)}
aria-label="Filter by status"
className=""
> >
<ListIcon /> {STATUS_OPTIONS.map((option) => (
</button> <option key={option.value} value={option.value}>
Status: {option.label}
</option>
))}
</Select>
<Select size="sm" value={sort} onChange={(e) => onSortChange(e.target.value)} aria-label="Sort by" className="">
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
Sort: {option.label}
</option>
))}
</Select>
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
<button
type="button"
aria-label="Grid view"
onClick={() => onViewChange('grid')}
className={`flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 transition-colors md:min-h-[32px] md:min-w-[32px] ${
view === 'grid' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
}`}
>
<GridIcon />
</button>
<button
type="button"
aria-label="List view"
onClick={() => onViewChange('list')}
className={`flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 transition-colors md:min-h-[32px] md:min-w-[32px] ${
view === 'list' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
}`}
>
<ListIcon />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -281,7 +304,7 @@ function Herd() {
// client-side below, 'all' shows everything. // client-side below, 'all' shows everything.
const apiStatus = isArchived || status === 'all' ? undefined : status const apiStatus = isArchived || status === 'all' ? undefined : status
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['rabbits', { sex, search: q, sort: apiSort, status: apiStatus }], queryKey: ['rabbits', { sex, search: q, sort: apiSort, status: apiStatus }],
queryFn: () => queryFn: () =>
getRabbits({ getRabbits({
@ -323,7 +346,7 @@ function Herd() {
/> />
{isError ? ( {isError ? (
<p className="font-body text-sm text-danger">Failed to load rabbits.</p> <ErrorPanel title="Couldn’t load your herd" error={error} onRetry={refetch} />
) : isLoading ? ( ) : isLoading ? (
view === 'list' ? <ListSkeleton /> : <GridSkeleton /> view === 'list' ? <ListSkeleton /> : <GridSkeleton />
) : rabbits.length === 0 ? ( ) : rabbits.length === 0 ? (

View File

@ -9,6 +9,7 @@ import LoadError from '../components/LoadError'
import { getLitter, deleteLitter } from '../api' import { getLitter, deleteLitter } from '../api'
import { GESTATION_DAYS, SEX_LABELS, WEAN_DAYS, addDays, formatDate, formatWeight } from '../lib/format' import { GESTATION_DAYS, SEX_LABELS, WEAN_DAYS, addDays, formatDate, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit' import { useWeightUnit } from '../hooks/useWeightUnit'
import { useConfirm } from '../hooks/useConfirm'
function ParentLink({ parent }) { function ParentLink({ parent }) {
if (!parent) return <p className="font-body text-sm text-text-secondary">Unknown</p> if (!parent) return <p className="font-body text-sm text-text-secondary">Unknown</p>
@ -25,6 +26,7 @@ function LitterDetail() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [confirm, confirmDialog] = useConfirm()
const { data: litter, isLoading, isError, error: loadError } = useQuery({ const { data: litter, isLoading, isError, error: loadError } = useQuery({
queryKey: ['litter', id], queryKey: ['litter', id],
queryFn: () => getLitter(id), queryFn: () => getLitter(id),
@ -50,8 +52,15 @@ function LitterDetail() {
const weanDate = kindled ? formatDate(addDays(litter.kindle_date, WEAN_DAYS)) : null const weanDate = kindled ? formatDate(addDays(litter.kindle_date, WEAN_DAYS)) : null
async function handleDelete() { async function handleDelete() {
const kitNote = kits.length ? ` Its ${kits.length} kit${kits.length === 1 ? '' : 's'} stay in your herd.` : '' const confirmed = await confirm({
if (!window.confirm(`Delete this ${kindled ? 'litter' : 'breeding'} record?${kitNote}`)) return title: `Delete this ${kindled ? 'litter' : 'breeding'}?`,
confirmLabel: 'Delete',
danger: true,
message: kits.length
? `Only the record is deleted — its ${kits.length} kit${kits.length === 1 ? '' : 's'} stay in your herd.`
: 'This can’t be undone.',
})
if (!confirmed) return
setDeleting(true) setDeleting(true)
setError(null) setError(null)
try { try {
@ -185,6 +194,7 @@ function LitterDetail() {
</Card> </Card>
)} )}
</div> </div>
{confirmDialog}
</div> </div>
) )
} }

View File

@ -4,6 +4,7 @@ import Button from '../components/Button'
import Card from '../components/Card' import Card from '../components/Card'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import LitterCard from '../components/LitterCard' import LitterCard from '../components/LitterCard'
import ErrorPanel from '../components/ErrorPanel'
import { getLitters } from '../api' import { getLitters } from '../api'
function LitterSection({ title, litters }) { function LitterSection({ title, litters }) {
@ -23,7 +24,7 @@ function LitterSection({ title, litters }) {
// A litter row spans breeding → kindle, so breedings that haven't kindled yet // A litter row spans breeding → kindle, so breedings that haven't kindled yet
// are listed apart from kindled litters. // are listed apart from kindled litters.
function Litters() { function Litters() {
const { data: litters, isLoading, isError } = useQuery({ queryKey: ['litters'], queryFn: getLitters }) const { data: litters, isLoading, isError, error, refetch } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
const expecting = (litters ?? []).filter((litter) => !litter.kindle_date) const expecting = (litters ?? []).filter((litter) => !litter.kindle_date)
const kindled = (litters ?? []).filter((litter) => litter.kindle_date) const kindled = (litters ?? []).filter((litter) => litter.kindle_date)
@ -44,7 +45,7 @@ function Litters() {
/> />
{isError ? ( {isError ? (
<p className="font-body text-sm text-danger">Failed to load litters.</p> <ErrorPanel title="Couldn’t load litters" error={error} onRetry={refetch} />
) : isLoading ? ( ) : isLoading ? (
<p className="font-body text-sm text-text-secondary">Loading…</p> <p className="font-body text-sm text-text-secondary">Loading…</p>
) : litters.length === 0 ? ( ) : litters.length === 0 ? (

View File

@ -6,12 +6,14 @@ import Card from '../components/Card'
import Badge from '../components/Badge' import Badge from '../components/Badge'
import Button from '../components/Button' import Button from '../components/Button'
import LoadError from '../components/LoadError' import LoadError from '../components/LoadError'
import ActionMenu from '../components/ActionMenu'
import Silhouette from '../components/Silhouette' import Silhouette from '../components/Silhouette'
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly' import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
import RabbitTimeline from '../components/RabbitTimeline' import RabbitTimeline from '../components/RabbitTimeline'
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api' import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format' import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit' import { useWeightUnit } from '../hooks/useWeightUnit'
import { useConfirm } from '../hooks/useConfirm'
import { LOCI } from '../lib/genetics' import { LOCI } from '../lib/genetics'
function locusLabel(locusKey, value) { function locusLabel(locusKey, value) {
@ -162,6 +164,7 @@ function RabbitDetail() {
const navigate = useNavigate() const navigate = useNavigate()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [confirm, confirmDialog] = useConfirm()
const { data: rabbit, isLoading, isError, error: loadError } = useQuery({ const { data: rabbit, isLoading, isError, error: loadError } = useQuery({
queryKey: ['rabbit', id], queryKey: ['rabbit', id],
@ -196,11 +199,21 @@ function RabbitDetail() {
} }
async function handleDelete() { async function handleDelete() {
const confirmed = window.confirm( const confirmed = await confirm({
`Permanently delete ${rabbit.name}? This can’t be undone.\n\n` + title: `Delete ${rabbit.name}?`,
'Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as unknown. ' + confirmLabel: 'Delete Rabbit',
'Its timeline events are deleted. To keep its history instead, change its status to Deceased, Sold or Other.' danger: true,
) message: (
<>
<p>This can’t be undone. Its timeline events are deleted too.</p>
<p>
Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as
unknown.
</p>
<p>To keep its history instead, change its status to Deceased, Sold or Other.</p>
</>
),
})
if (!confirmed) return if (!confirmed) return
setUpdating(true) setUpdating(true)
setError(null) setError(null)
@ -217,6 +230,17 @@ function RabbitDetail() {
// Breeding/litter actions pre-fill this rabbit as sire or dam, so they only // Breeding/litter actions pre-fill this rabbit as sire or dam, so they only
// make sense once its sex is known. // make sense once its sex is known.
const parentQuery = rabbit.sex === 'buck' ? `?sire=${rabbit.id}` : rabbit.sex === 'doe' ? `?dam=${rabbit.id}` : null const parentQuery = rabbit.sex === 'buck' ? `?sire=${rabbit.id}` : rabbit.sex === 'doe' ? `?dam=${rabbit.id}` : null
// Edit and Status stay in the header; the rest sit behind "More" so the
// header fits on one row on a phone.
const moreActions = [
...(parentQuery
? [
{ label: 'Record Breeding', to: `/breedings/add${parentQuery}` },
{ label: 'Record Litter', to: `/litters/add${parentQuery}` },
]
: []),
{ label: 'Print Pedigree', to: `/herd/${rabbit.id}/pedigree` },
]
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@ -228,20 +252,7 @@ function RabbitDetail() {
<Button to={`/herd/${rabbit.id}/edit`} size="sm"> <Button to={`/herd/${rabbit.id}/edit`} size="sm">
Edit Edit
</Button> </Button>
{parentQuery && ( <label className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 font-body text-sm text-text-secondary md:min-h-[32px]">
<>
<Button to={`/breedings/add${parentQuery}`} variant="secondary" size="sm">
Record Breeding
</Button>
<Button to={`/litters/add${parentQuery}`} variant="secondary" size="sm">
Record Litter
</Button>
</>
)}
<Button to={`/herd/${rabbit.id}/pedigree`} variant="secondary" size="sm">
Print Pedigree
</Button>
<label className="inline-flex min-h-[40px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 font-body text-sm text-text-secondary md:min-h-[32px]">
Status Status
<select <select
value={rabbit.status} value={rabbit.status}
@ -256,6 +267,7 @@ function RabbitDetail() {
))} ))}
</select> </select>
</label> </label>
<ActionMenu items={moreActions} />
</div> </div>
} }
/> />
@ -303,6 +315,7 @@ function RabbitDetail() {
</div> </div>
</div> </div>
</div> </div>
{confirmDialog}
</div> </div>
) )
} }

View File

@ -5,6 +5,8 @@ import Card from '../components/Card'
import Button from '../components/Button' import Button from '../components/Button'
import BreedSelect from '../components/BreedSelect' import BreedSelect from '../components/BreedSelect'
import UsersCard from '../components/UsersCard' import UsersCard from '../components/UsersCard'
import ConfirmDialog from '../components/ConfirmDialog'
import { Field, FieldGroup, Input, Segmented } from '../components/FormControls'
import { useAuth } from '../authContext' import { useAuth } from '../authContext'
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api' import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme' import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
@ -133,40 +135,32 @@ function Settings() {
<p className="mt-1 font-body text-sm text-text-secondary">Shared by everyone who signs in.</p> <p className="mt-1 font-body text-sm text-text-secondary">Shared by everyone who signs in.</p>
</div> </div>
<div> <Field label="Rabbitry Name">
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label> <Input
<input
type="text" type="text"
value={form.rabbitry_name} value={form.rabbitry_name}
onChange={(e) => setForm({ ...form, rabbitry_name: e.target.value })} onChange={(e) => setForm({ ...form, rabbitry_name: e.target.value })}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </Field>
<div> <Field
<label className="block font-body text-sm font-medium text-text-primary">Kit Prefix</label> label="Kit Prefix"
<input hint={`Auto-generated kit names use this prefix (e.g. "${form.kit_prefix || 'Kit'}1").`}
>
<Input
type="text" type="text"
value={form.kit_prefix} value={form.kit_prefix}
onChange={(e) => setForm({ ...form, kit_prefix: e.target.value })} onChange={(e) => setForm({ ...form, kit_prefix: e.target.value })}
placeholder="e.g. RAG" placeholder="e.g. RAG"
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<p className="mt-1 font-body text-xs text-text-secondary"> </Field>
Auto-generated kit names use this prefix (e.g. "{form.kit_prefix || 'Kit'}1").
</p>
</div>
<div> <Field label="Default Breed" hint="Pre-fills the breed field when adding a new rabbit.">
<label className="block font-body text-sm font-medium text-text-primary">Default Breed</label>
<BreedSelect <BreedSelect
value={form.default_breed} value={form.default_breed}
onChange={(breed) => setForm({ ...form, default_breed: breed })} onChange={(breed) => setForm({ ...form, default_breed: breed })}
/> />
<p className="mt-1 font-body text-xs text-text-secondary"> </Field>
Pre-fills the breed field when adding a new rabbit.
</p>
</div>
<Button onClick={() => handleSave(['rabbitry_name', 'kit_prefix', 'default_breed'])} disabled={saving}> <Button onClick={() => handleSave(['rabbitry_name', 'kit_prefix', 'default_breed'])} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbitry Settings'} {saving ? 'Saving…' : 'Save Rabbitry Settings'}
@ -178,32 +172,17 @@ function Settings() {
<h2 className="font-display text-lg text-text-primary">Your Preferences</h2> <h2 className="font-display text-lg text-text-primary">Your Preferences</h2>
<p className="mt-1 font-body text-sm text-text-secondary">Only affect your account.</p> <p className="mt-1 font-body text-sm text-text-secondary">Only affect your account.</p>
</div> </div>
<div> <FieldGroup label="Weight Display">
<label className="block font-body text-sm font-medium text-text-primary">Weight Display</label> <Segmented
<div className="mt-1 flex gap-1"> label="Weight Display"
{WEIGHT_UNIT_OPTIONS.map((opt) => ( options={WEIGHT_UNIT_OPTIONS}
<button value={form.weight_unit}
key={opt.value} onChange={(weight_unit) => setForm({ ...form, weight_unit })}
type="button" />
onClick={() => setForm({ ...form, weight_unit: opt.value })} </FieldGroup>
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
form.weight_unit === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div> <FieldGroup label="Theme" hint="Selecting a theme previews it instantly. Save to keep it across your devices.">
<label className="block font-body text-sm font-medium text-text-primary">Theme</label> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<p className="mt-1 font-body text-xs text-text-secondary">
Selecting a theme previews it instantly. Save to keep it across your devices.
</p>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{THEMES.map((theme) => ( {THEMES.map((theme) => (
<button <button
key={theme.id} key={theme.id}
@ -236,7 +215,7 @@ function Settings() {
</button> </button>
))} ))}
</div> </div>
</div> </FieldGroup>
<Button onClick={() => handleSave(['weight_unit', 'theme'])} disabled={saving}> <Button onClick={() => handleSave(['weight_unit', 'theme'])} disabled={saving}>
{saving ? 'Saving…' : 'Save Preferences'} {saving ? 'Saving…' : 'Save Preferences'}
@ -295,24 +274,19 @@ function Settings() {
</div> </div>
{pendingFile && ( {pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <ConfirmDialog
<Card className="w-full max-w-md space-y-4 p-6"> title="Replace all data?"
<h2 className="font-display text-lg text-text-primary">Replace all data?</h2> confirmLabel="Delete & Import"
<p className="font-body text-sm text-text-secondary"> busyLabel="Importing…"
Importing <span className="font-medium text-text-primary">{pendingFile.name}</span> will danger
permanently delete <span className="font-medium text-danger">all current rabbits and litters</span>{' '} busy={importing}
before loading the file. This cannot be undone. onConfirm={handleConfirmImport}
</p> onCancel={() => setPendingFile(null)}
<div className="flex justify-end gap-2"> >
<Button variant="secondary" onClick={() => setPendingFile(null)} disabled={importing}> Importing <span className="font-medium text-text-primary">{pendingFile.name}</span> will permanently
Cancel delete <span className="font-medium text-danger">all current rabbits and litters</span> before loading the
</Button> file. This cannot be undone.
<Button variant="danger" onClick={handleConfirmImport} disabled={importing}> </ConfirmDialog>
{importing ? 'Importing...' : 'Delete & Import'}
</Button>
</div>
</Card>
</div>
)} )}
<Toast message={toast} /> <Toast message={toast} />

View File

@ -16,6 +16,9 @@ import {
import Card from '../components/Card' import Card from '../components/Card'
import Button from '../components/Button' import Button from '../components/Button'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import LoadError from '../components/LoadError'
import { Field, Input, Textarea } from '../components/FormControls'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import RabbitPicker from '../components/RabbitPicker' import RabbitPicker from '../components/RabbitPicker'
import ReportModal from '../components/ReportModal' import ReportModal from '../components/ReportModal'
@ -48,19 +51,12 @@ function EditEventForm({ event, onDone }) {
return ( return (
<Card className="flex flex-col gap-3 p-4"> <Card className="flex flex-col gap-3 p-4">
<input <Field label="Event name" required>
type="text" <Input type="text" value={name} onChange={(e) => setName(e.target.value)} />
value={name} </Field>
onChange={(e) => setName(e.target.value)} <Field label="Notes">
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" <Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
/> </Field>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Notes"
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>} {mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onDone} disabled={mutation.isPending}> <Button variant="secondary" size="sm" onClick={onDone} disabled={mutation.isPending}>
@ -126,19 +122,23 @@ function JudgesSection({ eventId, show }) {
</ul> </ul>
) : null} ) : null}
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
<input <Input
size="sm"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Judge name" placeholder="Judge name"
className="flex-1 rounded-md border border-border px-2 py-1 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" aria-label="Judge name"
className="min-w-0 flex-1"
/> />
<input <Input
size="sm"
type="text" type="text"
value={arba} value={arba}
onChange={(e) => setArba(e.target.value)} onChange={(e) => setArba(e.target.value)}
placeholder="ARBA # (opt.)" placeholder="ARBA # (opt.)"
className="w-28 rounded-md border border-border px-2 py-1 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" aria-label="Judge ARBA number (optional)"
className="w-28"
/> />
<Button variant="secondary" size="sm" onClick={() => add.mutate()} disabled={name.trim().length === 0 || add.isPending}> <Button variant="secondary" size="sm" onClick={() => add.mutate()} disabled={name.trim().length === 0 || add.isPending}>
Add Judge Add Judge
@ -225,7 +225,7 @@ function PlacementBadges({ placement }) {
</span> </span>
) : null} ) : null}
{flags.map((f) => ( {flags.map((f) => (
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-surface"> <span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-on-accent-warm">
{f.label} {f.label}
</span> </span>
))} ))}
@ -267,13 +267,14 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<div className="flex flex-wrap items-end gap-3 rounded-md bg-surface-sunken p-3"> <div className="flex flex-wrap items-end gap-3 rounded-md bg-surface-sunken p-3">
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary"> <label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
Placement Placement
<input <Input
size="sm"
type="text" type="text"
value={form.placement} value={form.placement}
onChange={(e) => edit({ placement: e.target.value })} onChange={(e) => edit({ placement: e.target.value })}
onBlur={() => save.mutate(form)} onBlur={() => save.mutate(form)}
placeholder="1st, BOB…" placeholder="1st, BOB…"
className="w-28 rounded-md border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" className="w-28"
/> />
</label> </label>
@ -283,7 +284,7 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<button <button
type="button" type="button"
onClick={() => commit({ legs_earned: Math.max(0, form.legs_earned - 1) })} onClick={() => commit({ legs_earned: Math.max(0, form.legs_earned - 1) })}
className="rounded-md border border-border px-2 py-1 text-sm text-text-primary" className="min-h-[44px] min-w-[44px] rounded-md border border-border bg-surface-raised text-sm text-text-primary hover:bg-surface-sunken md:min-h-[32px] md:min-w-[32px]"
aria-label="Decrease legs" aria-label="Decrease legs"
> >
− −
@ -292,7 +293,7 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<button <button
type="button" type="button"
onClick={() => commit({ legs_earned: Math.min(3, form.legs_earned + 1) })} onClick={() => commit({ legs_earned: Math.min(3, form.legs_earned + 1) })}
className="rounded-md border border-border px-2 py-1 text-sm text-text-primary" className="min-h-[44px] min-w-[44px] rounded-md border border-border bg-surface-raised text-sm text-text-primary hover:bg-surface-sunken md:min-h-[32px] md:min-w-[32px]"
aria-label="Increase legs" aria-label="Increase legs"
> >
+ +
@ -314,12 +315,13 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary"> <label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
Notes Notes
<input <Input
size="sm"
type="text" type="text"
value={form.notes} value={form.notes}
onChange={(e) => edit({ notes: e.target.value })} onChange={(e) => edit({ notes: e.target.value })}
onBlur={() => save.mutate(form)} onBlur={() => save.mutate(form)}
className="w-36 rounded-md border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" className="w-36"
/> />
</label> </label>
@ -371,21 +373,25 @@ function EntryRow({ eventId, showId, entry, multiShow }) {
</p> </p>
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null} {meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
</div> </div>
<input <Input
size="sm"
type="text" type="text"
value={entryClass} value={entryClass}
onChange={(e) => setEntryClass(e.target.value)} onChange={(e) => setEntryClass(e.target.value)}
onBlur={saveClass} onBlur={saveClass}
placeholder="Class (e.g. Sr Buck)" placeholder="Class (e.g. Sr Buck)"
className="w-full rounded-md border border-border px-3 py-1.5 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent md:w-40" aria-label="Class"
className="w-full md:w-40"
/> />
<input <Input
size="sm"
type="text" type="text"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
onBlur={saveNotes} onBlur={saveNotes}
placeholder="Notes" placeholder="Notes"
className="w-full rounded-md border border-border px-3 py-1.5 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent md:w-44" aria-label="Entry notes"
className="w-full md:w-44"
/> />
<button <button
type="button" type="button"
@ -570,7 +576,7 @@ function ResultsTab({ event }) {
</span> </span>
) : null} ) : null}
{PLACEMENT_FLAGS.filter((f) => row.flags[f.key] > 0).map((f) => ( {PLACEMENT_FLAGS.filter((f) => row.flags[f.key] > 0).map((f) => (
<span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-surface"> <span key={f.key} className="rounded-full bg-accent-warm px-2 py-0.5 text-xs text-on-accent-warm">
{f.label} {f.label}
{row.flags[f.key] > 1 ? ` ×${row.flags[f.key]}` : ''} {row.flags[f.key] > 1 ? ` ×${row.flags[f.key]}` : ''}
</span> </span>
@ -618,8 +624,9 @@ function ShowEventDetail() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [tab, setTab] = useState('shows') const [tab, setTab] = useState('shows')
const [confirmingDelete, setConfirmingDelete] = useState(false)
const { data: event, isLoading, isError } = useQuery({ const { data: event, isLoading, isError, error: loadError } = useQuery({
queryKey: ['show-event', id], queryKey: ['show-event', id],
queryFn: () => getShowEvent(id), queryFn: () => getShowEvent(id),
}) })
@ -633,7 +640,7 @@ function ShowEventDetail() {
}) })
if (isError) { if (isError) {
return <p className="p-6 font-body text-sm text-danger">Failed to load this event.</p> return <LoadError title="Show Event" error={loadError} backTo="/shows/events" backLabel="Back to My Events" />
} }
if (isLoading || !event) { if (isLoading || !event) {
return <PageHeader title="Show Event" subtitle="Loading…" /> return <PageHeader title="Show Event" subtitle="Loading…" />
@ -655,9 +662,7 @@ function ShowEventDetail() {
</Button> </Button>
<Button <Button
variant="danger" variant="danger"
onClick={() => { onClick={() => setConfirmingDelete(true)}
if (window.confirm('Delete this event? Its shows and entries will be removed too.')) remove.mutate()
}}
disabled={remove.isPending} disabled={remove.isPending}
> >
Delete Delete
@ -707,6 +712,21 @@ function ShowEventDetail() {
) : ( ) : (
<ResultsTab event={event} /> <ResultsTab event={event} />
)} )}
{confirmingDelete && (
<ConfirmDialog
title={`Delete ${event.name}?`}
confirmLabel="Delete Event"
busyLabel="Deleting…"
danger
busy={remove.isPending}
onConfirm={() => remove.mutate()}
onCancel={() => setConfirmingDelete(false)}
>
<p>Its shows and entries will be removed too. This can’t be undone.</p>
{remove.isError && <p className="text-danger">{remove.error.message}</p>}
</ConfirmDialog>
)}
</div> </div>
) )
} }

View File

@ -5,6 +5,8 @@ import { getShowEvents, createShowEvent } from '../api'
import Card from '../components/Card' import Card from '../components/Card'
import Button from '../components/Button' import Button from '../components/Button'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import { Field, Input, Textarea } from '../components/FormControls'
import ErrorPanel from '../components/ErrorPanel'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
function formatDate(iso) { function formatDate(iso) {
@ -40,20 +42,17 @@ function NewEventForm({ onCreated }) {
return ( return (
<Card className="flex flex-col gap-3 p-4"> <Card className="flex flex-col gap-3 p-4">
<h3 className="font-display text-base text-text-primary">New event</h3> <h3 className="font-display text-base text-text-primary">New event</h3>
<input <Field label="Event name" required>
type="text" <Input
value={name} type="text"
onChange={(e) => setName(e.target.value)} value={name}
placeholder="Event name" onChange={(e) => setName(e.target.value)}
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent" placeholder="e.g. Spring Circuit Weekend"
/> />
<textarea </Field>
value={notes} <Field label="Notes">
onChange={(e) => setNotes(e.target.value)} <Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
rows={2} </Field>
placeholder="Notes (optional)"
className="rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>} {mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => onCreated(null)} disabled={mutation.isPending}> <Button variant="secondary" size="sm" onClick={() => onCreated(null)} disabled={mutation.isPending}>
@ -73,7 +72,7 @@ function NewEventForm({ onCreated }) {
} }
function ShowEvents() { function ShowEvents() {
const { data: events, isLoading, isError } = useQuery({ const { data: events, isLoading, isError, error, refetch } = useQuery({
queryKey: ['show-events'], queryKey: ['show-events'],
queryFn: getShowEvents, queryFn: getShowEvents,
}) })
@ -97,7 +96,7 @@ function ShowEvents() {
{creating && <NewEventForm onCreated={() => setCreating(false)} />} {creating && <NewEventForm onCreated={() => setCreating(false)} />}
{isError ? ( {isError ? (
<p className="font-body text-sm text-danger">Failed to load events.</p> <ErrorPanel title="Couldn’t load your events" error={error} onRetry={refetch} />
) : isLoading ? ( ) : isLoading ? (
<Card className="p-8"> <Card className="p-8">
<p className="font-body text-sm text-text-secondary">Loading events…</p> <p className="font-body text-sm text-text-secondary">Loading events…</p>

View File

@ -6,6 +6,7 @@ import Button from '../components/Button'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import AddToEventModal from '../components/AddToEventModal' import AddToEventModal from '../components/AddToEventModal'
import { Input } from '../components/FormControls'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@ -71,7 +72,7 @@ function MultiSelect({ label, options, selected, onChange }) {
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary" className="flex min-h-[44px] md:min-h-[32px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary"
> >
<span>{label}</span> <span>{label}</span>
{selected.length > 0 && ( {selected.length > 0 && (
@ -93,7 +94,7 @@ function MultiSelect({ label, options, selected, onChange }) {
{options.map((option) => ( {options.map((option) => (
<label <label
key={option.value} key={option.value}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 font-body text-sm text-text-primary hover:bg-surface-sunken" className="flex min-h-[40px] cursor-pointer items-center gap-2 rounded px-2 py-1 font-body md:min-h-0 text-sm text-text-primary hover:bg-surface-sunken"
> >
<input <input
type="checkbox" type="checkbox"
@ -118,6 +119,66 @@ function MapPinIcon() {
) )
} }
// Phone/tablet layout for one show: the same fields as a table row, stacked,
// so the list never scrolls sideways.
function ShowListCard({ show: s, onAdd }) {
const place = [s.city, s.state].filter(Boolean).join(', ')
return (
<Card className="flex flex-col gap-2 p-4 font-body text-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-text-primary">{s.club_name}</p>
<p className="text-xs text-text-secondary">
{s.show_type_full || s.show_type}
{s.special_breed ? ` · ${s.special_breed}` : ''}
</p>
</div>
<p className="shrink-0 whitespace-nowrap text-text-primary">{s.raw_date || s.date || '—'}</p>
</div>
<p className="text-text-secondary">
{place || '—'}
{s.district ? ` · District ${s.district}` : ''}
</p>
{s.secretary || s.email ? (
<p className="text-text-secondary">
{s.secretary}
{s.secretary && s.email ? ' · ' : ''}
{s.email ? (
<a href={`mailto:${s.email}`} className="break-all text-accent hover:underline">
{s.email}
</a>
) : null}
</p>
) : null}
<div className="flex flex-wrap items-center gap-x-4">
{s.maps_url ? (
<a
href={s.maps_url}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline"
>
<MapPinIcon /> Map
</a>
) : null}
{s.catalog_url ? (
<a
href={s.catalog_url}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-[44px] items-center text-accent hover:underline"
>
Catalog
</a>
) : null}
<Button variant="secondary" size="sm" onClick={onAdd} className="ml-auto">
Add to Event
</Button>
</div>
</Card>
)
}
function Shows() { function Shows() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
@ -222,36 +283,40 @@ function Shows() {
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary"> <label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
From From
<input <Input
size="sm"
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }} onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-sm text-text-primary focus:outline-none" className=""
/> />
</label> </label>
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary"> <label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
To To
<input <Input
size="sm"
type="date" type="date"
value={dateTo} value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }} onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 text-sm text-text-primary focus:outline-none" className=""
/> />
</label> </label>
<input <Input
size="sm"
type="text" type="text"
value={keyword} value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }} onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
placeholder="Club, city, or secretary" placeholder="Club, city, or secretary"
className="w-56 rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none" aria-label="Search by club, city, or secretary"
className="w-56"
/> />
{hasActiveFilters ? ( {hasActiveFilters ? (
<button <button
type="button" type="button"
onClick={resetFilters} onClick={resetFilters}
className="rounded-md px-3 py-1.5 font-body text-sm text-accent hover:bg-surface-sunken" className="min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm text-accent hover:bg-surface-sunken"
> >
Reset Filters Reset Filters
</button> </button>
@ -289,7 +354,13 @@ function Shows() {
<p className="font-body text-sm text-text-secondary"> <p className="font-body text-sm text-text-secondary">
{filtered.length} show{filtered.length === 1 ? '' : 's'} {filtered.length} show{filtered.length === 1 ? '' : 's'}
</p> </p>
<Card className="overflow-x-auto"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:hidden">
{pageRows.map((s, i) => (
<ShowListCard key={s.sanction_number || i} show={s} onAdd={() => setAddingShow(s)} />
))}
</div>
{/* The full table needs ~900px, which only fits beside the sidebar on wide screens. */}
<Card className="hidden overflow-x-auto xl:block">
<table className="w-full min-w-[900px] font-body text-sm"> <table className="w-full min-w-[900px] font-body text-sm">
<thead> <thead>
<tr className="border-b border-border text-left text-text-secondary"> <tr className="border-b border-border text-left text-text-secondary">

View File

@ -18,6 +18,7 @@ export default {
accent: 'var(--color-accent)', accent: 'var(--color-accent)',
'accent-hover': 'var(--color-accent-hover)', 'accent-hover': 'var(--color-accent-hover)',
'accent-warm': 'var(--color-accent-warm)', 'accent-warm': 'var(--color-accent-warm)',
'on-accent-warm': 'var(--color-on-accent-warm)',
danger: 'var(--color-danger)', danger: 'var(--color-danger)',
}, },
fontFamily: { fontFamily: {