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).
- **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).
- **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.

View File

@ -8,6 +8,8 @@ import {
setUnauthorizedHandler,
} from './api'
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
@ -29,28 +31,19 @@ function AuthShell({ title, subtitle, onSubmit, error, children }) {
)
}
function Field({ label, hint, ...inputProps }) {
function AuthField({ label, hint, ...inputProps }) {
return (
<div>
<label className="block font-body text-sm font-medium text-text-primary">{label}</label>
<input
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>
<Field label={label} hint={hint}>
<Input {...inputProps} />
</Field>
)
}
function SubmitButton({ disabled, children }) {
return (
<button
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"
>
<Button type="submit" disabled={disabled} className="w-full">
{children}
</button>
</Button>
)
}
@ -76,7 +69,7 @@ function LoginScreen({ onSuccess }) {
return (
<AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}>
<Field
<AuthField
label="Username"
type="text"
autoFocus
@ -84,7 +77,7 @@ function LoginScreen({ onSuccess }) {
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Field
<AuthField
label="Password"
type="password"
autoComplete="current-password"
@ -136,7 +129,7 @@ function SetupScreen({ onSuccess }) {
onSubmit={handleSubmit}
error={error}
>
<Field
<AuthField
label="Username"
type="text"
autoFocus
@ -144,7 +137,7 @@ function SetupScreen({ onSuccess }) {
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Field
<AuthField
label="Password"
type="password"
autoComplete="new-password"
@ -152,7 +145,7 @@ function SetupScreen({ onSuccess }) {
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Field
<AuthField
label="Confirm password"
type="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 { getShowEvents, createShowEvent, addShowToEvent } from '../api'
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.
// 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' ? (
<div>
<label className="block font-body text-sm font-medium text-text-primary">Event</label>
<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"
>
<Field label="Event">
<Select value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
<option value="">{isLoading ? 'Loading…' : 'Select an event'}</option>
{events?.map((event) => (
<option key={event.id} value={event.id}>
{event.name} ({event.show_count} show{event.show_count === 1 ? '' : 's'})
</option>
))}
</select>
</div>
</Select>
</Field>
) : (
<div className="flex flex-col gap-3">
<div>
<label className="block font-body text-sm font-medium text-text-primary">
Event name <span className="text-danger">*</span>
</label>
<input
<Field label="Event name" required>
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
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>
<div>
<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}
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>
<Field label="Notes">
<Textarea value={newNotes} onChange={(e) => setNewNotes(e.target.value)} rows={2} />
</Field>
</div>
)}

View File

@ -2,7 +2,7 @@ import { STATUS_LABELS } from '../lib/format'
const STATUS_CLASSES = {
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',
sold: 'bg-border text-text-primary',
deceased: 'bg-danger text-surface',

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Input } from './FormControls'
import { ARBA_BREEDS, isCustomBreed } from '../lib/breeds'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
@ -39,7 +40,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
return (
<div ref={containerRef} className="relative">
<input
<Input
ref={inputRef}
type="text"
value={value}
@ -53,9 +54,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
role="combobox"
aria-expanded={open}
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 ${
invalid ? 'border-danger' : 'border-border'
}`}
aria-invalid={invalid || undefined}
/>
{isCustomBreed(value) && (
@ -63,7 +62,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
)}
{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">
{matches.map((breed, index) => (
<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 RabbitAutocomplete from './RabbitAutocomplete'
import Button from './Button'
import { Field, FieldGroup, Input, Textarea } from './FormControls'
// Shared by AddLitter/EditLitter (kindle records) and AddBreeding (breeding
// records). A litter row spans the breeding→kindle lifecycle, so both a breeding
@ -51,87 +52,58 @@ function LitterForm({
return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4">
<div>
<label className="block font-body text-xs text-text-secondary">Sire</label>
<div className="mt-0.5">
<FieldGroup label="Sire">
<RabbitAutocomplete sex="buck" value={sire} onChange={setSire} />
</div>
</div>
</FieldGroup>
<div>
<label className="block font-body text-xs text-text-secondary">Dam</label>
<div className="mt-0.5">
<FieldGroup label="Dam">
<RabbitAutocomplete sex="doe" value={dam} onChange={setDam} />
</div>
</div>
</FieldGroup>
<div>
<label className="block font-body text-xs text-text-secondary">
{isBreeding ? 'Breeding Date *' : 'Breeding Date'}
</label>
<input
<Field
label="Breeding Date"
required={isBreeding}
error={isBreeding && showValidation ? 'Breeding date is required' : null}
>
<Input
type="date"
value={breedDate}
onChange={(e) => {
setBreedDate(e.target.value)
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 && (
<p className="mt-1 font-body text-xs text-danger">Breeding date is required</p>
)}
</div>
</Field>
{!isBreeding && (
<>
<div>
<label className="block font-body text-xs text-text-secondary">Kindle Date *</label>
<input
<Field label="Kindle Date" required error={showValidation ? 'Kindle date is required' : null}>
<Input
type="date"
value={kindleDate}
onChange={(e) => {
setKindleDate(e.target.value)
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 && (
<p className="mt-1 font-body text-xs text-danger">Kindle date is required</p>
)}
</div>
</Field>
<div>
<label className="block font-body text-xs text-text-secondary">Kit Count</label>
<input
type="number"
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
<Field
label="Kit Count"
hint={
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.'
: 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'}
</p>
</div>
: 'Changing the count doesn’t add or remove kit records — use Add Kit on the litter page.'
}
>
<Input type="number" min="0" value={kitCount} onChange={(e) => setKitCount(e.target.value)} />
</Field>
</>
)}
<div>
<label className="block font-body text-xs text-text-secondary">Notes</label>
<textarea
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>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
{error && <p className="font-body text-sm text-danger">{error}</p>}

View File

@ -1,6 +1,5 @@
import Button from './Button'
import Card from './Card'
import EmptyState from './EmptyState'
import ErrorPanel from './ErrorPanel'
import PageHeader from './PageHeader'
// 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 (
<div className="flex flex-col gap-6">
<PageHeader title={title} />
<Card>
<EmptyState
<ErrorPanel
title={notFound ? `${title} not found` : `Couldn’t load ${title.toLowerCase()}`}
description={
notFound
? 'It may have been deleted, or the link is out of date.'
: error?.message || 'Check your connection and try again.'
}
error={notFound ? { message: 'It may have been deleted, or the link is out of date.' } : error}
action={backTo ? <Button to={backTo}>{backLabel}</Button> : null}
/>
</Card>
</div>
)
}

View File

@ -52,7 +52,7 @@ function PedigreeScan({ result, imageUrl, onScanned, confirmReplace }) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
if (confirmReplace && !confirmReplace()) return
if (confirmReplace && !(await confirmReplace())) return
setScanning(true)
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="truncate">{label}</span>
{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

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'
import Badge from './Badge'
import Card from './Card'
import Silhouette from './Silhouette'
import { SERVER_BASE } from '../api'
@ -14,7 +15,7 @@ function RabbitCard({ rabbit }) {
return (
<Link to={`/herd/${rabbit.id}`}>
<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 ? (
<img
src={`${SERVER_BASE}${rabbit.photo_path}`}
@ -24,6 +25,13 @@ function RabbitCard({ rabbit }) {
) : (
<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 className="flex flex-col gap-1 p-3">
<p className="truncate font-display text-base text-text-primary">{rabbit.name}</p>

View File

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

View File

@ -2,21 +2,68 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Button from './Button'
import { Field, Input, Select, Textarea } from './FormControls'
import { getLitters, getEvents, getRabbitShowEntries, createEvent, updateEvent, deleteEvent } from '../api'
import { formatDate } from '../lib/format'
import { useConfirm } from '../hooks/useConfirm'
// 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).
// `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 = {
birth: { icon: '🐣', order: 0, dot: 'bg-accent text-surface' },
acquired: { icon: '🏡', order: 1, dot: 'bg-accent-warm text-surface' },
breeding: { icon: '❤', order: 2, dot: 'bg-surface-sunken text-text-primary' },
kindle: { icon: '🐰', order: 3, dot: 'bg-accent text-surface' },
show: { icon: '🏆', order: 4, dot: 'bg-accent-warm text-surface' },
sale: { icon: '🏷️', order: 5, dot: 'bg-accent text-surface' },
other: { icon: '📌', order: 6, dot: 'bg-surface-sunken text-text-primary' },
death: { icon: '✝', order: 7, dot: 'bg-danger text-white' },
birth: {
icon: <path d="M12 3c3.5 0 6 5 6 10a6 6 0 0 1-12 0c0-5 2.5-10 6-10Z" />,
order: 0,
dot: 'bg-accent text-surface',
},
acquired: {
icon: <path d="M4 11.5 12 4l8 7.5M6.5 10v10h11V10" />,
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
@ -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 }) {
const [form, setForm] = useState(
editingEvent
@ -160,81 +204,54 @@ function EventForm({ editingEvent, onSave, onCancel, saving }) {
Edit {ADD_EVENT_TYPES.find((t) => t.value === form.type)?.label ?? 'Event'}
</p>
) : (
<div>
<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)}
className={`${inputClass} bg-surface-raised`}
>
<Field label="Event Type">
<Select value={form.type} onChange={(e) => setField('type', e.target.value)}>
{ADD_EVENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
</Select>
</Field>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block font-body text-sm font-medium text-text-primary">
Date <span className="text-danger">*</span>
</label>
<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
<Field label="Date" required>
<Input type="date" value={form.event_date} onChange={(e) => setField('event_date', e.target.value)} />
</Field>
<Field label={TITLE_LABEL[form.type]} required>
<Input
type="text"
value={form.title}
onChange={(e) => setField('title', e.target.value)}
placeholder={TITLE_PLACEHOLDER[form.type]}
className={inputClass}
/>
</div>
</Field>
{isShow && (
<>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Class</label>
<input
<Field label="Class">
<Input
type="text"
value={form.show_class}
onChange={(e) => setField('show_class', e.target.value)}
placeholder="e.g. Senior Buck"
className={inputClass}
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Placement</label>
<input
</Field>
<Field label="Placement">
<Input
type="text"
value={form.placement}
onChange={(e) => setField('placement', e.target.value)}
placeholder="e.g. BOB, 1st"
className={inputClass}
/>
</div>
</Field>
</>
)}
</div>
<div>
<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}
className={inputClass}
/>
</div>
<Field label="Notes">
<Textarea value={form.notes} onChange={(e) => setField('notes', e.target.value)} rows={2} />
</Field>
{isShow && !editingEvent && (
<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">
<div className="flex flex-col items-center">
<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}`}
>
<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>
{!isLast && <span className="w-px flex-1 bg-border" />}
</div>
@ -320,6 +348,7 @@ function RabbitTimeline({ rabbit }) {
const [formMode, setFormMode] = useState(null) // null | 'add' | event id being edited
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [confirm, confirmDialog] = useConfirm()
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
const { data: logged } = useQuery({
@ -362,7 +391,8 @@ function RabbitTimeline({ rabbit }) {
}
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)
try {
await deleteEvent(ev.id)
@ -422,6 +452,7 @@ function RabbitTimeline({ rabbit }) {
))}
</ol>
)}
{confirmDialog}
</div>
)
}

View File

@ -2,14 +2,13 @@ import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Card from './Card'
import Button from './Button'
import ConfirmDialog from './ConfirmDialog'
import { Field, Input } from './FormControls'
import { getUsers, createUser, resetUserPassword, deleteUser } from '../api'
import { useAuth } from '../authContext'
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
// 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
@ -116,19 +115,15 @@ export default function UsersCard({ onToast }) {
</div>
{resetTarget === user.id && (
<div className="mt-2 flex items-end gap-2">
<div className="flex-1">
<label className="block font-body text-xs text-text-secondary">
New password (min {MIN_PASSWORD_LENGTH} characters)
</label>
<input
<Field label={`New password (min ${MIN_PASSWORD_LENGTH} characters)`} className="flex-1">
<Input
type="password"
autoFocus
autoComplete="new-password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
className={inputClass}
/>
</div>
</Field>
<Button
onClick={() => handleReset(user)}
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">
<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>
<label className="block font-body text-xs text-text-secondary">Username</label>
<input
<Field label="Username">
<Input
type="text"
autoComplete="off"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block font-body text-xs text-text-secondary">
Password (min {MIN_PASSWORD_LENGTH} characters)
</label>
<input
</Field>
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters`}>
<Input
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
/>
</div>
</Field>
</div>
{addError && <p className="font-body text-sm text-danger">{addError}</p>}
<Button
@ -178,25 +167,18 @@ export default function UsersCard({ onToast }) {
</form>
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<Card className="w-full max-w-md space-y-4 p-6">
<h2 className="font-display text-lg text-text-primary">
Remove {deleteTarget.username}?
</h2>
<p className="font-body text-sm text-text-secondary">
They will be signed out everywhere and can no longer log in. The herd data is shared,
so nothing else is deleted.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteTarget(null)} disabled={busy}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} disabled={busy}>
{busy ? 'Removing…' : 'Remove User'}
</Button>
</div>
</Card>
</div>
<ConfirmDialog
title={`Remove ${deleteTarget.username}?`}
confirmLabel="Remove User"
busyLabel="Removing…"
danger
busy={busy}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
>
They will be signed out everywhere and can no longer log in. The herd data is shared, so nothing else is
deleted.
</ConfirmDialog>
)}
</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
complete strings, so the per-tile names are spelled out, not built up). */
: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-muted: var(--color-text-secondary);
--stat-1: var(--color-surface-raised);
@ -64,6 +69,7 @@
--color-accent-hover: #94b884;
--color-accent-warm: #d9954a;
--color-danger: #d46a6a;
--color-on-accent-warm: var(--color-surface);
}
[data-theme='ocean'] {
@ -72,7 +78,7 @@
--color-surface-sunken: #e1eaf0;
--color-border: #c2d3de;
--color-text-primary: #14222b;
--color-text-secondary: #5a7484;
--color-text-secondary: #516a79;
--color-accent: #2e6e8e;
--color-accent-hover: #245870;
--color-accent-warm: #e0913b;
@ -98,9 +104,9 @@
--color-surface-sunken: #f4e6da;
--color-border: #e6cdb8;
--color-text-primary: #2a1c14;
--color-text-secondary: #8a6f5c;
--color-accent: #c9542e;
--color-accent-hover: #a8431f;
--color-text-secondary: #7a604d;
--color-accent: #b84a26;
--color-accent-hover: #9a3d1c;
--color-accent-warm: #e0913b;
--color-danger: #b33a3a;
}

View File

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

View File

@ -9,6 +9,7 @@ import ReviewStep from '../components/ReviewStep'
import StickyActionBar from '../components/StickyActionBar'
import PedigreeScan from '../components/PedigreeScan'
import Button from '../components/Button'
import { Field, Input } from '../components/FormControls'
import {
getSettings,
getRabbit,
@ -17,6 +18,7 @@ import {
getPedigreeScanStatus,
findExistingRabbit,
} from '../api'
import { useConfirm } from '../hooks/useConfirm'
import { createInitialPedigreeState, pedigreeStateFromScan, uncertainScanSlots } from '../lib/pedigree'
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
@ -153,6 +155,7 @@ function AddRabbit() {
const [scanImageUrl, setScanImageUrl] = useState(null)
const [pedigreeSeed, setPedigreeSeed] = useState(null)
const [pedigreeKey, setPedigreeKey] = useState(0)
const [confirm, confirmDialog] = useConfirm()
const step2Valid = details.name.trim().length > 0
@ -266,7 +269,14 @@ function AddRabbit() {
// Scanning replaces the tree wholesale, so confirm before discarding entries.
function confirmReplaceWithScan() {
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) {
@ -339,19 +349,15 @@ function AddRabbit() {
(or jumping from Review via "Edit") keeps the pedigree tree's
internal state, the photo preview, and any open sections intact. */}
<div className={step === 1 ? 'flex flex-col gap-4' : 'hidden'}>
<div>
<label htmlFor="add-rabbit-subject-name" className="block font-body text-sm font-medium text-text-primary">
Rabbit name
</label>
<input
id="add-rabbit-subject-name"
<Field label="Rabbit name">
<Input
type="text"
value={details.name}
onChange={(e) => updateDetails({ name: e.target.value })}
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">
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.
@ -426,6 +432,7 @@ function AddRabbit() {
</Button>
)}
</StickyActionBar>
{confirmDialog}
</div>
)
}

View File

@ -4,6 +4,7 @@ import { getStats, getLitters, getRabbits, getSettings } from '../api'
import Card from '../components/Card'
import Button from '../components/Button'
import EmptyState from '../components/EmptyState'
import ErrorPanel from '../components/ErrorPanel'
import PageHeader from '../components/PageHeader'
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: '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)]' },
// 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: '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)]' },
@ -26,17 +29,19 @@ const STAT_CARDS = [
]
function StatsBar() {
const { data, isLoading, isError } = useQuery({ queryKey: ['stats'], queryFn: getStats })
const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['stats'], queryFn: getStats })
if (isError) {
return (
<p className="font-body text-sm text-danger">Failed to load dashboard stats.</p>
)
return <ErrorPanel title="Couldn’t load herd counts" error={error} onRetry={refetch} />
}
const primary = STAT_CARDS.slice(0, 4)
const secondary = STAT_CARDS.slice(4)
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{STAT_CARDS.map((card) =>
{primary.map((card) =>
isLoading ? (
<Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" />
) : (
@ -52,14 +57,29 @@ function StatsBar() {
<span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]">
{card.label}
</span>
<span className="font-display text-2xl text-[var(--stat-fg)]">
{data[card.key]}
</span>
<span className="font-display text-2xl text-[var(--stat-fg)]">{data[card.key]}</span>
</div>
</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>
)
}
@ -69,7 +89,6 @@ function QuickActions() {
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
<Button to="/breedings/add" variant="secondary">Record Breeding</Button>
<Button to="/litters/add" variant="secondary">Record Litter</Button>
<Button to="/herd" variant="secondary">View Herd</Button>
</div>
)
}

View File

@ -7,6 +7,7 @@ import GeneticsForm from '../components/GeneticsForm'
import RabbitAutocomplete from '../components/RabbitAutocomplete'
import StickyActionBar from '../components/StickyActionBar'
import LoadError from '../components/LoadError'
import { FieldGroup } from '../components/FormControls'
import Button from '../components/Button'
import { getRabbit, getRabbitAncestors, getSettings, updateRabbit, uploadRabbitPhoto } from '../api'
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.
function ParentField({ label, sex, value, onChange, manualName }) {
return (
<div>
<span className="block font-body text-sm font-medium text-text-primary">{label}</span>
<div className="mt-1">
<FieldGroup label={label}>
<RabbitAutocomplete sex={sex} value={value} onChange={onChange} />
</div>
{!value && manualName && (
<p className="mt-1 font-body text-xs text-text-secondary">
Recorded as {formatManualAncestorDisplay(manualName)} — not linked to a herd record.
</p>
)}
</div>
</FieldGroup>
)
}
@ -174,7 +172,7 @@ function EditRabbit() {
/>
<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">
Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
</p>

View File

@ -8,6 +8,8 @@ import EmptyState from '../components/EmptyState'
import PageHeader from '../components/PageHeader'
import Button from '../components/Button'
import RabbitCard from '../components/RabbitCard'
import { Select } from '../components/FormControls'
import ErrorPanel from '../components/ErrorPanel'
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
@ -86,7 +88,7 @@ function SearchBox({ q, onSearchChange }) {
}, [searchInput])
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 />
<input
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 }) {
const [filtersOpen, setFiltersOpen] = useState(false)
const changedCount = (status !== DEFAULT_STATUS ? 1 : 0) + (sort !== 'name' ? 1 : 0)
return (
<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">
@ -109,7 +117,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
key={tab.value}
type="button"
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
? 'bg-accent text-surface'
: 'text-text-primary hover:bg-surface-sunken'
@ -121,38 +129,52 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex w-full gap-2 md:w-auto">
<SearchBox key={q} q={q} onSearchChange={onSearchChange} />
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setFiltersOpen((v) => !v)}
aria-expanded={filtersOpen}
className="shrink-0 gap-1.5 md:hidden"
>
Filters
{changedCount > 0 && (
<span className="rounded-full bg-accent px-1.5 text-xs text-surface">{changedCount}</span>
)}
</Button>
</div>
<select
<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)}
className="rounded-md border border-border bg-surface-raised px-3 py-1.5 font-body text-sm text-text-primary focus:outline-none"
aria-label="Filter by status"
className=""
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
Status: {option.label}
</option>
))}
</select>
</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"
>
<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>
</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={`rounded-md p-1.5 transition-colors ${
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'
}`}
>
@ -162,7 +184,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
type="button"
aria-label="List view"
onClick={() => onViewChange('list')}
className={`rounded-md p-1.5 transition-colors ${
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'
}`}
>
@ -171,6 +193,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
</div>
</div>
</div>
</div>
)
}
@ -281,7 +304,7 @@ function Herd() {
// client-side below, 'all' shows everything.
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 }],
queryFn: () =>
getRabbits({
@ -323,7 +346,7 @@ function Herd() {
/>
{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 ? (
view === 'list' ? <ListSkeleton /> : <GridSkeleton />
) : rabbits.length === 0 ? (

View File

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

View File

@ -4,6 +4,7 @@ import Button from '../components/Button'
import Card from '../components/Card'
import EmptyState from '../components/EmptyState'
import LitterCard from '../components/LitterCard'
import ErrorPanel from '../components/ErrorPanel'
import { getLitters } from '../api'
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
// are listed apart from kindled 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 kindled = (litters ?? []).filter((litter) => litter.kindle_date)
@ -44,7 +45,7 @@ function Litters() {
/>
{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 ? (
<p className="font-body text-sm text-text-secondary">Loading…</p>
) : litters.length === 0 ? (

View File

@ -6,12 +6,14 @@ import Card from '../components/Card'
import Badge from '../components/Badge'
import Button from '../components/Button'
import LoadError from '../components/LoadError'
import ActionMenu from '../components/ActionMenu'
import Silhouette from '../components/Silhouette'
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
import RabbitTimeline from '../components/RabbitTimeline'
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
import { useWeightUnit } from '../hooks/useWeightUnit'
import { useConfirm } from '../hooks/useConfirm'
import { LOCI } from '../lib/genetics'
function locusLabel(locusKey, value) {
@ -162,6 +164,7 @@ function RabbitDetail() {
const navigate = useNavigate()
const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null)
const [confirm, confirmDialog] = useConfirm()
const { data: rabbit, isLoading, isError, error: loadError } = useQuery({
queryKey: ['rabbit', id],
@ -196,11 +199,21 @@ function RabbitDetail() {
}
async function handleDelete() {
const confirmed = window.confirm(
`Permanently delete ${rabbit.name}? This can’t be undone.\n\n` +
'Its kits keep a written copy of it in their pedigrees, and litters it was part of show that parent as unknown. ' +
'Its timeline events are deleted. To keep its history instead, change its status to Deceased, Sold or Other.'
)
const confirmed = await confirm({
title: `Delete ${rabbit.name}?`,
confirmLabel: 'Delete Rabbit',
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
setUpdating(true)
setError(null)
@ -217,6 +230,17 @@ function RabbitDetail() {
// Breeding/litter actions pre-fill this rabbit as sire or dam, so they only
// make sense once its sex is known.
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 (
<div className="flex flex-col gap-6">
@ -228,20 +252,7 @@ function RabbitDetail() {
<Button to={`/herd/${rabbit.id}/edit`} size="sm">
Edit
</Button>
{parentQuery && (
<>
<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]">
<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]">
Status
<select
value={rabbit.status}
@ -256,6 +267,7 @@ function RabbitDetail() {
))}
</select>
</label>
<ActionMenu items={moreActions} />
</div>
}
/>
@ -303,6 +315,7 @@ function RabbitDetail() {
</div>
</div>
</div>
{confirmDialog}
</div>
)
}

View File

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

View File

@ -16,6 +16,9 @@ import {
import Card from '../components/Card'
import Button from '../components/Button'
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 RabbitPicker from '../components/RabbitPicker'
import ReportModal from '../components/ReportModal'
@ -48,19 +51,12 @@ function EditEventForm({ event, onDone }) {
return (
<Card className="flex flex-col gap-3 p-4">
<input
type="text"
value={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"
/>
<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"
/>
<Field label="Event name" required>
<Input type="text" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
</Field>
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onDone} disabled={mutation.isPending}>
@ -126,19 +122,23 @@ function JudgesSection({ eventId, show }) {
</ul>
) : null}
<div className="mt-2 flex flex-wrap gap-2">
<input
<Input
size="sm"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
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"
value={arba}
onChange={(e) => setArba(e.target.value)}
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}>
Add Judge
@ -225,7 +225,7 @@ function PlacementBadges({ placement }) {
</span>
) : null}
{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}
</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">
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
Placement
<input
<Input
size="sm"
type="text"
value={form.placement}
onChange={(e) => edit({ placement: e.target.value })}
onBlur={() => save.mutate(form)}
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>
@ -283,7 +284,7 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<button
type="button"
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"
>
−
@ -292,7 +293,7 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<button
type="button"
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"
>
+
@ -314,12 +315,13 @@ function PlacementEditor({ eventId, showId, entryId, placement }) {
<label className="flex flex-col gap-1 font-body text-xs text-text-secondary">
Notes
<input
<Input
size="sm"
type="text"
value={form.notes}
onChange={(e) => edit({ notes: e.target.value })}
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>
@ -371,21 +373,25 @@ function EntryRow({ eventId, showId, entry, multiShow }) {
</p>
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
</div>
<input
<Input
size="sm"
type="text"
value={entryClass}
onChange={(e) => setEntryClass(e.target.value)}
onBlur={saveClass}
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"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={saveNotes}
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
type="button"
@ -570,7 +576,7 @@ function ResultsTab({ event }) {
</span>
) : null}
{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}
{row.flags[f.key] > 1 ? ` ×${row.flags[f.key]}` : ''}
</span>
@ -618,8 +624,9 @@ function ShowEventDetail() {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
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],
queryFn: () => getShowEvent(id),
})
@ -633,7 +640,7 @@ function ShowEventDetail() {
})
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) {
return <PageHeader title="Show Event" subtitle="Loading…" />
@ -655,9 +662,7 @@ function ShowEventDetail() {
</Button>
<Button
variant="danger"
onClick={() => {
if (window.confirm('Delete this event? Its shows and entries will be removed too.')) remove.mutate()
}}
onClick={() => setConfirmingDelete(true)}
disabled={remove.isPending}
>
Delete
@ -707,6 +712,21 @@ function ShowEventDetail() {
) : (
<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>
)
}

View File

@ -5,6 +5,8 @@ import { getShowEvents, createShowEvent } from '../api'
import Card from '../components/Card'
import Button from '../components/Button'
import PageHeader from '../components/PageHeader'
import { Field, Input, Textarea } from '../components/FormControls'
import ErrorPanel from '../components/ErrorPanel'
import EmptyState from '../components/EmptyState'
function formatDate(iso) {
@ -40,20 +42,17 @@ function NewEventForm({ onCreated }) {
return (
<Card className="flex flex-col gap-3 p-4">
<h3 className="font-display text-base text-text-primary">New event</h3>
<input
<Field label="Event name" required>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Event name"
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}
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"
placeholder="e.g. Spring Circuit Weekend"
/>
</Field>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
</Field>
{mutation.isError && <p className="font-body text-sm text-danger">{mutation.error.message}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => onCreated(null)} disabled={mutation.isPending}>
@ -73,7 +72,7 @@ function NewEventForm({ onCreated }) {
}
function ShowEvents() {
const { data: events, isLoading, isError } = useQuery({
const { data: events, isLoading, isError, error, refetch } = useQuery({
queryKey: ['show-events'],
queryFn: getShowEvents,
})
@ -97,7 +96,7 @@ function ShowEvents() {
{creating && <NewEventForm onCreated={() => setCreating(false)} />}
{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 ? (
<Card className="p-8">
<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 EmptyState from '../components/EmptyState'
import AddToEventModal from '../components/AddToEventModal'
import { Input } from '../components/FormControls'
const PAGE_SIZE = 50
@ -71,7 +72,7 @@ function MultiSelect({ label, options, selected, onChange }) {
<button
type="button"
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>
{selected.length > 0 && (
@ -93,7 +94,7 @@ function MultiSelect({ label, options, selected, onChange }) {
{options.map((option) => (
<label
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
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() {
const queryClient = useQueryClient()
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">
From
<input
<Input
size="sm"
type="date"
value={dateFrom}
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 className="flex flex-col gap-1 font-body text-xs text-text-secondary">
To
<input
<Input
size="sm"
type="date"
value={dateTo}
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>
<input
<Input
size="sm"
type="text"
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
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 ? (
<button
type="button"
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
</button>
@ -289,7 +354,13 @@ function Shows() {
<p className="font-body text-sm text-text-secondary">
{filtered.length} show{filtered.length === 1 ? '' : 's'}
</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">
<thead>
<tr className="border-b border-border text-left text-text-secondary">

View File

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