Compare commits

..

2 Commits

Author SHA1 Message Date
fireshaper 5d29e2b370 Rework Add Rabbit flow for mobile and clarity
- Pedigree renders as stacked Sire/Dam sections below 1280px instead of a
  sideways-scrolling tree; larger touch targets in the ancestor popup
- Step lives in ?step= so the back gesture moves between steps; sticky
  Back/Next/Save bar and a clickable stepper; beforeunload guard
- Steps stay mounted, fixing pedigree entries lost when going back
- Review shows formatted values, hides empty rows, per-section Edit links
- Failed photo upload after create now lands on the new rabbit with a
  notice instead of allowing a duplicate save
- Details form: primary fields first, secondary fields collapsible on
  Add, name error only after a Next attempt; "Use as color name" button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:05:35 -04:00
fireshaper 3874c33a90 Document updating an existing Docker deployment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:05:35 -04:00
13 changed files with 747 additions and 350 deletions

View File

@ -62,10 +62,12 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
| `src/lib/ocrPreprocess.js` | OCR Pedigree Scanning, Phase 2. Canvas-only (no `jimp`) image normalization for OCR. Exports `preprocessImage(file)` — decode → downscale to `MAX_WIDTH` (1600px, never upscaling) → grayscale (luminance) → binarize to pure black/white in one pixel pass → return a PNG `Blob`. Also exports the tunable `MAX_WIDTH` and `BINARIZE_THRESHOLD` (binarization cutoff, tuned to **103** during Step 2.2 — see progress.md; a fixed global threshold, deliberately not Otsu). This processed blob is the canonical image for the whole OCR pipeline: the scanner OCRs it and the zone picker's bounding boxes live in its coordinate space, so the original file is discarded after preprocessing. |
| `src/lib/pedigree.js` | Pedigree slot definitions (`GEN1`/`GEN2`/`GEN3`, 14 ancestor slots mapping 1:1 to the schema's `_name_text` columns) and the client-side chain-authority resolver. `resolveEffectiveNode(path, state, cascadeCache)` mirrors `server/routes/rabbits.ts`'s `resolveAncestorNode` — see the pedigree convention note below. Also exports `seedPedigreeStateFromRabbit`/`cascadeFromAncestors` (used by `EditRabbit.jsx` to pre-populate from an existing record) and `buildPedigreePayload`/`buildPedigreeSummary` (flat API-ready fields vs. a display-only summary array, both derived from the same resolver). `serializeManualAncestor`/`parseManualAncestor`/`formatManualAncestorDisplay` encode/decode a manually-entered ancestor's Farm/Name/Ear Number/Weight/DOB as a JSON string in that same `_name_text` column — see the manual-ancestor convention note below. |
| `src/lib/genetics.js` | `LOCI` (option config for all seven loci pill-groups) and `generateColorString(loci)`, which combines A/B/D/E/C/En into a single description string (`vv` and `cc` short-circuit to "BEW"/"REW"). Also `hasAnyLocusSet` (drives `GeneticsForm`'s default-expanded state on Edit). |
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. |
| `src/components/RabbitDetailsForm.jsx` | Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a `weightUnit` prop; photo selection is reported via a separate `onPhotoChange(file)` callback since it's always a second request (`uploadRabbitPhoto`) after the rabbit row exists. |
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. |
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared `dash()` helper for empty values. |
| `src/components/PedigreeTree.jsx` | Self-contained pedigree input (Phase 7 Step 7.1) — manages its own `state`/`cascadeCache` and reports out via `onChange(fields, summary)` rather than being a fully controlled component, since its internal shape (per-slot mode/id/name plus an async ancestor cache) isn't something a parent should need to reconstruct. Accepts optional `initialState`/`initialCascade` for `EditRabbit.jsx`. A locked (cascade-derived) node renders non-interactively with a 🔒 and is not clickable — see the pedigree convention note below. **Responsive layout:** at `≥1280px` (`useMediaQuery`, `hooks/useMediaQuery.js`) it renders the 3-column tree; below that the same `AncestorSlot`s render as a stacked, indented list (`StackedBranch`) in "Sire's side"/"Dam's side" cards, so phones never scroll sideways. Popup drop-direction accounts for the bottom chrome (action bar + mobile nav). |
| `src/components/RabbitDetailsForm.jsx` | Controlled form (Step 7.2) over the same snake_case field names the API uses. Weight toggles between one decimal input and an lbs+oz pair based on a `weightUnit` prop; photo selection is reported via a separate `onPhotoChange(file)` callback since it's always a second request (`uploadRabbitPhoto`) after the rabbit row exists. Primary fields (Name, Farm, Ear #, Sex, DOB, Breed, Color, Weight, Photo) come first; Status/Location/Acquired/Death sit in a "More details" section that `collapseSecondary` (Add flow only) starts closed. The name error only shows when `showValidation` is set (Add sets it after a failed Next). |
| `src/components/GeneticsForm.jsx` | Collapsible pill-group genetics section (Step 7.3) over `lib/genetics.js`'s `LOCI` config, with a read-only generated color string field. Optional `onUseColor(color)` adds a "Use as color name" button (Add + Edit wire it to `details.color_name`). |
| `src/components/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save. Formats values for display (sex/status labels, `formatDate`, `formatWeight` in the user's unit), hides empty rows (Sire/Dam always shown; deeper ancestors only when set) and shows genetics as "Not set" when empty. Each section has an Edit button calling `onEdit(step)`. Action buttons live in `AddRabbit`'s `StickyActionBar`, not here. |
| `src/components/StickyActionBar.jsx` | Bottom-pinned form action row (sticky above the mobile bottom nav, `bottom-0` on desktop). Used by the Add Rabbit flow for Back/Next/Save. |
| `src/pages/AddRabbit.jsx` (flow notes) | The current step is a `?step=` URL param (pushed, so the Back gesture moves between steps; step 3 falls back to 2 without a name). All steps stay mounted and are hidden with CSS so `PedigreeTree`'s internal state survives going back / Review "Edit". Step 1 has an optional name field feeding the tree's root label. A `beforeunload` guard warns on reload/close with unsaved input (in-app link navigation is not guarded — `BrowserRouter` has no `useBlocker`). On save, a failed photo upload still navigates to the created rabbit with a `location.state.notice` (shown by `RabbitDetail`) instead of leaving the user on Review, where re-saving would create a duplicate. |
| `src/lib/theme.js` | Theme registry (post-Phase-11 feature) — the single source of truth for the *list* of selectable themes (`classic`/`midnight`/`ocean`/`berry`/`sunset`/`carnival`), their labels/descriptions, the Settings preview swatches, and each theme's browser-chrome `theme-color`. The actual palettes are CSS variables in `index.css` (one `[data-theme='…']` block per theme); this module only knows ids + preview metadata. `applyTheme(id)` sets `document.documentElement.dataset.theme` (which swaps the variables), updates the `theme-color` meta, and mirrors the id to `localStorage` (key `mykindlebox-theme`). `normalizeTheme` falls back to `classic` for unknown ids so a stray DB/cache value never leaves the app unstyled. See the theming convention note below. |
| `src/lib/breeds.js` | The ARBA-recognized rabbit breed list (`ARBA_BREEDS`) powering the breed picker, plus `isCustomBreed(breed)` (true for a non-empty value not on the list). Suggestion list only — `BreedSelect` keeps a free-text fallback, so a breed not on this list still saves. Add new ARBA breeds here. |
| `src/components/BreedSelect.jsx` | The breed picker (combobox): a text input bound **directly** to `value` (so anything typed is a valid free-text breed) with a filtered dropdown of `ARBA_BREEDS` underneath for guided selection. Reuses `lib/keyboardNav.js` for the same arrow-key roving focus as `RabbitAutocomplete`/`PedigreeTree`. Shows a "Custom breed" hint when the value isn't an ARBA breed. Used by `RabbitDetailsForm.jsx` (the rabbit Breed field) and `Settings.jsx` (the Default Breed setting). |

View File

@ -90,7 +90,7 @@ and reverse-proxies `/api` + `/uploads` to the Node server. The SQLite database
and uploaded photos live in the `server-data` Docker volume.
To serve on a different host port, set `MYKINDLEBOX_PORT` (e.g. in a `.env` file
next to `docker-compose.yml`, alongside `MYKINDLEBOX_PASSWORD`):
next to `docker-compose.yml`):
```env
MYKINDLEBOX_PORT=3000
@ -100,6 +100,51 @@ Then re-run `docker compose up -d` — the app is served on that port instead of
8080. (Only the host side changes; inside the compose network nginx still
listens on 80.)
### Updating an existing deployment
Compose builds from whatever is checked out on the host, so pull first, then
rebuild. Run from the repo root on the server:
```bash
# 1. Back up the database (schema migrations run automatically on startup)
cd mykindlebox
docker compose cp server:/app/data ./data-backup-$(date +%F)
# 2. Pull the latest code
cd ..
git checkout main
git pull
git log --oneline -1 # confirm you're on the commit you expect
# 3. Rebuild and restart both services
cd mykindlebox
docker compose up --build -d
```
**Verify both containers were recreated:**
```bash
docker compose ps
```
Both `client` and `server` should show a recent **CREATED** time (seconds or
minutes ago), and `server` should reach **(healthy)** within ~15 seconds. If
either still shows an old CREATED time, Compose reused a cached image — force
it:
```bash
docker compose build --no-cache server # or client
docker compose up -d --force-recreate server
```
The client and server must be from the same version; a new client against an
old server will fail on login and newer pages. If the server never becomes
healthy, check `docker compose logs server --tail 50`.
Finally, hard-refresh the browser (Ctrl+Shift+R). On an installed phone PWA,
fully close and reopen the app to pick up the new build. Once everything looks
right, the `data-backup-*` folder can be deleted.
### Authentication
The app uses **user accounts** — everyone shares the same herd; accounts are
@ -116,4 +161,9 @@ last 30 days, surviving server restarts.
The herd database (`server/data/`), uploaded photos, and `.env` files are
**gitignored** — they are never committed. In production they persist in the
`server-data` volume; back that volume up to preserve your data.
`server-data` volume; back that volume up to preserve your data. A quick
snapshot from a running stack:
```bash
docker compose cp server:/app/data ./data-backup-$(date +%F)
```

View File

@ -1,3 +1,5 @@
import { STATUS_LABELS } from '../lib/format'
const STATUS_CLASSES = {
active: 'bg-accent text-surface',
for_sale: 'bg-accent-warm text-surface',
@ -7,15 +9,6 @@ const STATUS_CLASSES = {
other: 'bg-border text-text-primary',
}
const STATUS_LABELS = {
active: 'Active',
for_sale: 'For Sale',
on_hold: 'On Hold',
sold: 'Sold',
deceased: 'Deceased',
other: 'Other',
}
function Badge({ status }) {
return (
<span

View File

@ -1,7 +1,24 @@
import { useState } from 'react'
import { LOCI, generateColorString } from '../lib/genetics'
function GeneticsForm({ value, onChange, defaultExpanded = false }) {
function ChevronIcon({ open }) {
return (
<svg
viewBox="0 0 20 20"
className={`h-4 w-4 text-text-secondary transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<path d="m5 8 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
// `onUseColor`, when given, adds a button that copies the generated color
// string into the rabbit's Color Name field.
function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor }) {
const [expanded, setExpanded] = useState(defaultExpanded)
const colorString = generateColorString(value)
@ -14,24 +31,26 @@ function GeneticsForm({ value, onChange, defaultExpanded = false }) {
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center justify-between px-4 py-3 font-body text-sm font-medium text-text-primary"
>
<span>Genetics (optional)</span>
<span className="text-text-secondary">{expanded ? '▲' : '▼'}</span>
<ChevronIcon open={expanded} />
</button>
{expanded && (
<div className="space-y-3 border-t border-border px-4 py-4">
{LOCI.map((locus) => (
<div key={locus.key} className="flex flex-wrap items-center gap-2">
<div key={locus.key} className="flex flex-col gap-1.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span>
<div className="flex flex-wrap gap-1.5">
{locus.options.map((opt) => (
<button
key={opt.value}
type="button"
aria-pressed={value[locus.key] === opt.value}
onClick={() => setLocus(locus.key, opt.value)}
className={`rounded-full border px-3 py-1 font-body text-xs ${
className={`rounded-full border px-3 py-1.5 font-body text-sm md:py-1 md:text-xs ${
value[locus.key] === opt.value
? 'border-accent bg-accent text-surface'
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
@ -48,13 +67,25 @@ function GeneticsForm({ value, onChange, defaultExpanded = false }) {
<label className="block font-body text-sm font-medium text-text-primary">
Generated color string
</label>
<div className="mt-1 flex gap-2">
<input
type="text"
readOnly
value={colorString}
placeholder="Select loci to generate a description"
className="mt-1 w-full rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary"
className="min-w-0 flex-1 rounded-md border border-border bg-surface-sunken px-3 py-2 font-body text-sm text-text-secondary"
/>
{onUseColor && (
<button
type="button"
disabled={!colorString}
onClick={() => onUseColor(colorString)}
className="shrink-0 rounded-md border border-border px-3 py-2 font-body text-sm font-medium text-text-primary hover:bg-surface-sunken disabled:cursor-not-allowed disabled:opacity-50"
>
Use as color name
</button>
)}
</div>
</div>
</div>
)}

View File

@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { searchRabbits, getRabbitAncestors } from '../api'
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
import { useMediaQuery } from '../hooks/useMediaQuery'
import {
ALL_SLOTS,
GEN1,
GEN2,
GEN3,
@ -21,6 +23,32 @@ const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0',
// mounts, so the very first click is positioned correctly.
const POPUP_MAX_HEIGHT = 360
// Viewport space at the bottom covered by fixed/sticky chrome (the Add Rabbit
// action bar, plus the bottom nav on phones), so a popup that would open
// underneath it flips upward instead.
function bottomChromeHeight() {
return window.matchMedia('(max-width: 767px)').matches ? 136 : 72
}
// The 3-column tree needs ~900px of content width; below that (phones,
// tablets, small laptops with the sidebar) the same slots render as a stacked,
// indented list instead of a sideways-scrolling tree.
const WIDE_TREE_QUERY = '(min-width: 1280px)'
const SLOT_BY_PATH = new Map(ALL_SLOTS.map((entry) => [entry.path.join('.'), entry]))
function LockIcon() {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 text-text-secondary" fill="currentColor" aria-label="Locked">
<path
fillRule="evenodd"
d="M10 2a4 4 0 0 0-4 4v2H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-1V6a4 4 0 0 0-4-4Zm2 6V6a2 2 0 1 0-4 0v2h4Z"
clipRule="evenodd"
/>
</svg>
)
}
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
// is the separate "Unknown" checkbox. They collapse into one stored string
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
@ -55,7 +83,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
function updateDropUp() {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom
const spaceAbove = rect.top
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
}
@ -102,9 +130,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<p className="truncate font-body text-xs text-text-secondary">{label}</p>
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
</div>
<span aria-label="locked" className="text-text-secondary">
🔒
</span>
<LockIcon />
</div>
)
}
@ -166,7 +192,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
{open && (
<div
className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${
className={`absolute left-0 z-30 w-full min-w-[16rem] max-w-sm rounded-md border border-border bg-surface-raised p-2 shadow-lg ${
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
@ -174,14 +200,14 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<button
type="button"
onClick={() => setMode('search')}
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
>
Search existing
</button>
<button
type="button"
onClick={() => setMode('manual')}
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
className={`flex-1 rounded px-2 py-2 text-sm md:py-1 md:text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
>
Enter manually
</button>
@ -197,7 +223,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
placeholder="Search by name…"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
/>
<ul className="mt-1 max-h-40 overflow-auto">
{results.map((rabbit, index) => (
@ -213,7 +239,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
close,
})
}
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken"
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
>
{rabbit.name}
{rabbit.farm ? ` (${rabbit.farm})` : ''}
@ -235,21 +261,21 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
value={manualForm.farm}
onChange={(e) => setManualField('farm', e.target.value)}
placeholder="Farm Name *"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
/>
<input
type="text"
value={manualForm.name}
onChange={(e) => setManualField('name', e.target.value)}
placeholder="Rabbit Name *"
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
className="w-full rounded border border-border px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
/>
<input
type="text"
value={manualForm.ear_number}
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
placeholder="Ear Number *"
className="w-full rounded border border-border px-2 py-1 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
className="w-full rounded border border-border px-2 py-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<div>
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
@ -258,7 +284,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
step="0.01"
value={manualForm.weight_lbs}
onChange={(e) => setManualField('weight_lbs', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
@ -268,7 +294,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
value={manualForm.dob}
disabled={manualForm.dobUnknown}
onChange={(e) => setManualField('dob', e.target.value)}
className="mt-0.5 w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
className="mt-0.5 w-full rounded border border-border px-2 py-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-text-secondary"
/>
</div>
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
@ -290,7 +316,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
type="button"
onClick={saveManual}
disabled={!canSaveManual}
className="w-full rounded bg-accent px-2 py-1 text-xs text-surface disabled:opacity-50"
className="w-full rounded bg-accent px-2 py-2 text-sm font-medium text-surface disabled:opacity-50 md:py-1.5 md:text-xs"
>
Save
</button>
@ -300,7 +326,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
<button
type="button"
onClick={markUnknown}
className="mt-2 w-full rounded px-2 py-1 text-left text-xs text-text-secondary hover:bg-surface-sunken"
className="mt-2 w-full rounded px-2 py-2 text-left text-sm text-text-secondary hover:bg-surface-sunken md:py-1 md:text-xs"
>
Mark as Unknown
</button>
@ -329,6 +355,29 @@ function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade }
)
}
// One ancestor plus, indented beneath it, its own sire/dam branches — the
// stacked (narrow-screen) rendering of the same slots the wide tree shows.
function StackedBranch({ path, slotProps }) {
const entry = SLOT_BY_PATH.get(path.join('.'))
const childPaths = [
[...path, 'sire'],
[...path, 'dam'],
].filter((p) => SLOT_BY_PATH.has(p.join('.')))
return (
<div className="flex flex-col gap-2">
<AncestorSlot path={entry.path} slot={entry.slot} label={entry.label} {...slotProps} />
{childPaths.length > 0 && (
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
{childPaths.map((childPath) => (
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
))}
</div>
)}
</div>
)
}
function PedigreeTree({
subjectName = 'New Rabbit',
initialState,
@ -341,6 +390,7 @@ function PedigreeTree({
// sets state synchronously (it's called from a mount effect below). An id is
// never removed: on failure the cache stores null, and we don't retry.
const cascadeFetchStarted = useRef(new Set())
const wide = useMediaQuery(WIDE_TREE_QUERY)
function setSlotState(slot, value) {
setState((prev) => ({ ...prev, [slot]: value }))
@ -370,12 +420,24 @@ function PedigreeTree({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!wide) {
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
return (
// The tree needs ~900px; on narrower screens the outer wrapper scrolls
// sideways rather than the min-width stretching the whole page.
<div className="-mx-4 overflow-x-auto md:mx-0">
<p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p>
<div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]">
<div className="flex flex-col gap-4">
{GEN1.map(({ path, slot }) => (
<section key={slot} className="rounded-lg border border-border bg-surface-raised p-3 shadow-sm">
<h3 className="mb-2 font-display text-base font-bold text-text-primary">
{slot === 'sire' ? "Sire's side" : "Dam's side"}
</h3>
<StackedBranch path={path} slotProps={slotProps} />
</section>
))}
</div>
)
}
return (
<div className="flex w-full items-center gap-6 p-4 pb-[300px]">
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
</div>
@ -383,7 +445,6 @@ function PedigreeTree({
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
</div>
</div>
)
}

View File

@ -16,6 +16,10 @@ 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)
@ -31,8 +35,26 @@ function ozToLbs(lbs, oz) {
return l + o / 16
}
function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decimal_lbs', showValidation = false }) {
// The fields most people fill in when adding a rabbit come first; the rest
// (status, location, acquired/death dates) sit in a "More details" section.
// With `collapseSecondary` (the Add flow) that section starts closed unless it
// already holds a non-default value; Edit shows everything open.
function RabbitDetailsForm({
value,
onChange,
onPhotoChange,
weightUnit = 'decimal_lbs',
showValidation = false,
collapseSecondary = false,
nameInputId = 'rabbit-name',
}) {
const [photoPreview, setPhotoPreview] = useState(null)
const [moreOpen, setMoreOpen] = useState(
() =>
!collapseSecondary ||
Boolean(value.location || value.acquired_date || value.death_date) ||
(value.status ?? 'active') !== 'active'
)
const { lbs: ozLbs, oz: ozOz } = lbsToOz(value.weight_lbs)
function setField(field, fieldValue) {
@ -49,41 +71,60 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
const nameInvalid = showValidation && !value.name?.trim()
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 className="block font-body text-sm font-medium text-text-primary">
<label htmlFor={nameInputId} className={LABEL_CLASS}>
Name <span className="text-danger">*</span>
</label>
<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 className="mt-1 font-body text-xs text-danger">Name is required</p>}
{nameInvalid && (
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
Give this rabbit a name to continue.
</p>
)}
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Farm / Prefix</label>
<label className={LABEL_CLASS}>Farm / Prefix</label>
<input
type="text"
value={value.farm ?? ''}
onChange={(e) => setField('farm', 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"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Sex</label>
<div className="mt-1 flex gap-1">
<label className={LABEL_CLASS}>Ear Number</label>
<input
type="text"
value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
className={`${INPUT_CLASS} uppercase`}
/>
</div>
<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={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
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'
@ -96,97 +137,110 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label>
<label className={LABEL_CLASS}>Date of Birth</label>
<input
type="date"
value={value.dob ?? ''}
onChange={(e) => setField('dob', 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"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Acquired Date</label>
<label className={LABEL_CLASS}>Breed</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
</div>
<div>
<label className={LABEL_CLASS}>Color Name</label>
<input
type="date"
value={value.acquired_date ?? ''}
onChange={(e) => setField('acquired_date', e.target.value || null)}
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">If added to the herd after birth</p>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Death Date</label>
<input
type="date"
value={value.death_date ?? ''}
onChange={(e) => setField('death_date', e.target.value || null)}
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"
type="text"
value={value.color_name ?? ''}
onChange={(e) => setField('color_name', e.target.value)}
placeholder="e.g. Broken Black Tort"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Weight</label>
<label className={LABEL_CLASS}>Weight</label>
{weightUnit === 'lbs_oz' ? (
<div className="mt-1 flex gap-2">
<input
type="number"
inputMode="numeric"
min="0"
value={ozLbs}
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"
/>
<input
type="number"
inputMode="numeric"
min="0"
max="15"
value={ozOz}
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"
/>
</div>
) : (
<input
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={value.weight_lbs ?? ''}
onChange={(e) => setField('weight_lbs', e.target.value === '' ? null : Number(e.target.value))}
placeholder="decimal lbs"
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"
className={INPUT_CLASS}
/>
)}
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Ear Number</label>
<div className="md:col-span-2">
<label className={LABEL_CLASS}>Photo</label>
<input
type="text"
value={value.ear_number ?? ''}
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
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 && (
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
)}
</div>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Location</label>
<input
type="text"
value={value.location ?? ''}
onChange={(e) => setField('location', 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>
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
{collapseSecondary && (
<button
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"
>
<span>More details</span>
<span className="font-normal text-text-secondary">Status, location, dates</span>
</button>
)}
{moreOpen && (
<div
className={`grid grid-cols-1 gap-4 md:grid-cols-2 ${
collapseSecondary ? 'border-t border-border px-4 py-4' : ''
}`}
>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Status</label>
<label className={LABEL_CLASS}>Status</label>
<select
value={value.status ?? 'active'}
onChange={(e) => setField('status', 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"
className={`${INPUT_CLASS} bg-surface-raised`}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
@ -197,31 +251,37 @@ function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decim
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Breed</label>
<BreedSelect value={value.breed ?? ''} onChange={(breed) => setField('breed', breed)} />
<label className={LABEL_CLASS}>Location</label>
<input
type="text"
value={value.location ?? ''}
onChange={(e) => setField('location', e.target.value)}
placeholder="Cage or hutch"
className={INPUT_CLASS}
/>
</div>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Color Name</label>
<label className={LABEL_CLASS}>Acquired Date</label>
<input
type="text"
value={value.color_name ?? ''}
onChange={(e) => setField('color_name', e.target.value)}
placeholder="e.g. Broken Black Tort"
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"
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>
<div className="md:col-span-2">
<label className="block font-body text-sm font-medium text-text-primary">Photo</label>
<div>
<label className={LABEL_CLASS}>Death Date</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"
type="date"
value={value.death_date ?? ''}
onChange={(e) => setField('death_date', e.target.value || null)}
className={INPUT_CLASS}
/>
{photoPreview && (
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
</div>
</div>
)}
</div>
</div>

View File

@ -1,74 +1,98 @@
import Button from './Button'
import Card from './Card'
import { LOCI, generateColorString } from '../lib/genetics'
import { SEX_LABELS, STATUS_LABELS, formatDate, formatWeight } from '../lib/format'
const DETAIL_FIELDS = [
{ key: 'name', label: 'Name' },
{ key: 'farm', label: 'Farm / Prefix' },
{ key: 'sex', label: 'Sex' },
{ key: 'dob', label: 'Date of Birth' },
{ key: 'weight_lbs', label: 'Weight (lbs)' },
{ key: 'ear_number', label: 'Ear Number' },
{ key: 'location', label: 'Location' },
{ key: 'status', label: 'Status' },
{ key: 'breed', label: 'Breed' },
{ key: 'color_name', label: 'Color Name' },
]
function dash(value) {
return value === null || value === undefined || value === '' ? '—' : String(value)
// Display order + formatting for the Details card. Empty values are skipped
// (except Name, which is required), so the review shows what was entered
// rather than a wall of dashes.
function detailRows(details, weightUnit) {
return [
['Name', details.name],
['Farm / Prefix', details.farm],
['Ear Number', details.ear_number],
['Sex', SEX_LABELS[details.sex] ?? details.sex],
['Date of Birth', formatDate(details.dob)],
['Breed', details.breed],
['Color Name', details.color_name],
['Weight', formatWeight(details.weight_lbs, weightUnit)],
['Status', STATUS_LABELS[details.status] ?? details.status],
['Location', details.location],
['Acquired', formatDate(details.acquired_date)],
['Death Date', formatDate(details.death_date)],
].filter(([label, value]) => label === 'Name' || (value !== null && value !== undefined && value !== ''))
}
function ReviewStep({ details, pedigreeSummary, genetics, onBack, onSave, saving = false }) {
function locusLabel(locus, value) {
return locus.options.find((opt) => opt.value === value)?.label ?? value
}
function Section({ title, onEdit, children }) {
return (
<div className="space-y-6">
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Pedigree</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{pedigreeSummary.map((node) => (
<div key={node.slot} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{node.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(node.display)}</dd>
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="font-display text-lg font-bold text-text-primary">{title}</h3>
{onEdit && (
<button
type="button"
onClick={onEdit}
className="-my-2 rounded-md px-3 py-2 font-body text-sm font-medium text-accent hover:bg-surface-sunken"
>
Edit
</button>
)}
</div>
))}
</dl>
{children}
</Card>
)
}
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Details</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{DETAIL_FIELDS.map((field) => (
<div key={field.key} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{field.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(details[field.key])}</dd>
function Rows({ rows }) {
return (
<dl className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 border-b border-border py-2">
<dt className="shrink-0 font-body text-sm text-text-secondary">{label}</dt>
<dd className="min-w-0 break-words text-right font-body text-sm text-text-primary">{value || '—'}</dd>
</div>
))}
</dl>
</Card>
)
}
<Card className="p-4">
<h3 className="mb-3 font-display text-lg text-text-primary">Genetics</h3>
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
{LOCI.map((locus) => (
<div key={locus.key} className="flex justify-between gap-2 border-b border-border py-1">
<dt className="font-body text-sm text-text-secondary">{locus.label}</dt>
<dd className="font-body text-sm text-text-primary">{dash(genetics[locus.key])}</dd>
</div>
))}
</dl>
function ReviewStep({ details, pedigreeSummary, genetics, weightUnit, onEdit }) {
// Sire and Dam always show (so "Unknown" parents are visible at a glance);
// deeper ancestors only when something was actually entered.
const pedigreeRows = pedigreeSummary
.filter((node) => node.slot === 'sire' || node.slot === 'dam' || (node.display && node.display !== 'Unknown'))
.map((node) => [node.label, node.display])
const setLoci = LOCI.filter((locus) => genetics[locus.key])
const colorString = generateColorString(genetics)
return (
<div className="space-y-4">
<Section title="Pedigree" onEdit={() => onEdit(1)}>
<Rows rows={pedigreeRows} />
</Section>
<Section title="Details" onEdit={() => onEdit(2)}>
<Rows rows={detailRows(details, weightUnit)} />
</Section>
<Section title="Genetics" onEdit={() => onEdit(2)}>
{setLoci.length === 0 ? (
<p className="font-body text-sm text-text-secondary">Not set — you can add genotypes later from Edit.</p>
) : (
<>
<Rows rows={setLoci.map((locus) => [locus.label, locusLabel(locus, genetics[locus.key])])} />
{colorString && (
<p className="mt-3 font-body text-sm text-text-secondary">
Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</span>
Color: <span className="text-text-primary">{colorString}</span>
</p>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onBack} disabled={saving}>
Back
</Button>
<Button type="button" variant="primary" onClick={onSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
</Button>
</div>
)}
</>
)}
</Section>
</div>
)
}

View File

@ -0,0 +1,13 @@
// Form actions pinned to the bottom of the viewport while the page scrolls, so
// Next/Save is always in thumb reach. On phones it sits just above the fixed
// bottom nav (56px + safe-area inset, see Layout.jsx); the negative margins
// stretch it across <main>'s gutter.
function StickyActionBar({ children }) {
return (
<div className="sticky bottom-[calc(3.5rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t border-border bg-surface px-4 py-3 md:bottom-0 md:-mx-8 md:px-8">
<div className="flex items-center justify-end gap-3">{children}</div>
</div>
)
}
export default StickyActionBar

View File

@ -0,0 +1,14 @@
import { useSyncExternalStore } from 'react'
// Live boolean for a CSS media query, for the rare layout switch that CSS
// alone can't do (e.g. rendering a structurally different component tree).
export function useMediaQuery(query) {
return useSyncExternalStore(
(onChange) => {
const mql = window.matchMedia(query)
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
},
() => window.matchMedia(query).matches
)
}

View File

@ -4,6 +4,15 @@ export const SEX_LABELS = {
unknown: 'Unknown',
}
export const STATUS_LABELS = {
active: 'Active',
for_sale: 'For Sale',
on_hold: 'On Hold',
sold: 'Sold',
deceased: 'Deceased',
other: 'Other',
}
export function formatAge(dob) {
if (!dob) return 'Age unknown'

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
@ -6,11 +6,13 @@ import PedigreeTree from '../components/PedigreeTree'
import RabbitDetailsForm from '../components/RabbitDetailsForm'
import GeneticsForm from '../components/GeneticsForm'
import ReviewStep from '../components/ReviewStep'
import StickyActionBar from '../components/StickyActionBar'
import Button from '../components/Button'
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
import { createInitialPedigreeState } from '../lib/pedigree'
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
const NAME_INPUT_ID = 'add-rabbit-name'
const INITIAL_DETAILS = {
name: '',
@ -35,14 +37,54 @@ const INITIAL_GENETICS = {
locus_v: null,
}
function Stepper({ step, canReach, onSelect }) {
return (
<ol className="flex items-center gap-2">
{STEP_LABELS.map((label, i) => {
const n = i + 1
const current = n === step
const done = n < step
return (
<li key={label} className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
onClick={() => onSelect(n)}
disabled={current || !canReach(n)}
aria-current={current ? 'step' : undefined}
className="flex min-h-[40px] min-w-0 items-center gap-2 rounded-md pr-2 text-left disabled:cursor-default"
>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full font-body text-sm font-medium ${
current
? 'bg-accent text-surface'
: done
? 'border-2 border-accent text-accent'
: 'border border-border text-text-secondary'
}`}
>
{n}
</span>
<span
className={`truncate font-body text-sm ${current ? 'font-medium text-text-primary' : 'text-text-secondary'}`}
>
{label}
</span>
</button>
{n < STEP_LABELS.length && <span className="h-px flex-1 bg-border" aria-hidden="true" />}
</li>
)
})}
</ol>
)
}
function AddRabbit() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const sireId = searchParams.get('sire')
const damId = searchParams.get('dam')
const prefillDob = searchParams.get('dob')
const [step, setStep] = useState(1)
const [details, setDetails] = useState(() => ({
...INITIAL_DETAILS,
dob: prefillDob ?? INITIAL_DETAILS.dob,
@ -54,6 +96,35 @@ function AddRabbit() {
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [detailsDefaulted, setDetailsDefaulted] = useState(false)
// The name error only shows once the user has tried to move past step 2,
// not the moment they arrive on an empty form.
const [triedNext, setTriedNext] = useState(false)
const step2Valid = details.name.trim().length > 0
// The step lives in the URL (pushed, not replaced) so the browser / Android
// back gesture moves between steps instead of abandoning the whole flow.
// Review can't be reached without a name (e.g. after a reload, which resets
// the form), so it falls back to Details.
const requestedStep = Math.min(3, Math.max(1, Number(searchParams.get('step')) || 1))
const step = requestedStep === 3 && !step2Valid ? 2 : requestedStep
const isDirty =
Boolean(details.name.trim()) ||
Boolean(photoFile) ||
Object.values(genetics).some(Boolean) ||
pedigreeSummary.some((node) => node.display && node.display !== 'Unknown')
// Warn before a reload / tab close throws away an in-progress entry.
useEffect(() => {
if (!isDirty || saving) return
function onBeforeUnload(e) {
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [isDirty, saving])
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
@ -102,85 +173,145 @@ function AddRabbit() {
setGenetics((prev) => ({ ...prev, ...partial }))
}
function goToStep(n) {
const next = new URLSearchParams(searchParams)
if (n === 1) next.delete('step')
else next.set('step', String(n))
setSearchParams(next)
window.scrollTo(0, 0)
}
function handleNextFromDetails() {
if (!step2Valid) {
setTriedNext(true)
document.getElementById(NAME_INPUT_ID)?.focus()
return
}
goToStep(3)
}
async function handleSave() {
setSaving(true)
setError(null)
let created
try {
const created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
if (photoFile) {
await uploadRabbitPhoto(created.id, photoFile)
}
navigate(`/herd/${created.id}`)
created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
} catch (err) {
setError(err.message)
setSaving(false)
return
}
// The rabbit exists from here on. A failed photo upload must not leave the
// user on Review, where pressing Save again would create a duplicate —
// go to the new rabbit and say what happened instead.
if (photoFile) {
try {
await uploadRabbitPhoto(created.id, photoFile)
} catch {
navigate(`/herd/${created.id}`, {
replace: true,
state: { notice: 'Rabbit saved, but the photo didn’t upload. You can add it again from Edit.' },
})
return
}
}
const step2Valid = details.name.trim().length > 0
const headerAction =
step === 1 ? (
<Button type="button" onClick={() => setStep(2)}>
Next
</Button>
) : step === 2 ? (
<div className="flex gap-3">
<Button type="button" variant="secondary" onClick={() => setStep(1)}>
Back
</Button>
<Button type="button" onClick={() => setStep(3)} disabled={!step2Valid}>
Next
</Button>
</div>
) : null
navigate(`/herd/${created.id}`, { replace: true })
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Add Rabbit"
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
action={headerAction}
/>
<PageHeader title="Add Rabbit" />
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
{/* All three steps stay mounted and are hidden with CSS, so going back
(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>
{step === 1 && (
<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"
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"
/>
</div>
<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.
</p>
<PedigreeTree
subjectName={details.name.trim() || 'New Rabbit'}
initialState={pedigreeInitialState}
onChange={(fields, summary) => {
setPedigreeFields(fields)
setPedigreeSummary(summary)
}}
/>
)}
</div>
{step === 2 && (
<div className="space-y-6">
<div className={step === 2 ? 'space-y-6' : 'hidden'}>
<RabbitDetailsForm
value={details}
onChange={updateDetails}
onPhotoChange={setPhotoFile}
weightUnit={settings?.weight_unit}
showValidation={!step2Valid}
showValidation={triedNext}
collapseSecondary
nameInputId={NAME_INPUT_ID}
/>
<GeneticsForm
value={genetics}
onChange={updateGenetics}
defaultExpanded={false}
onUseColor={(color) => updateDetails({ color_name: color })}
/>
<GeneticsForm value={genetics} onChange={updateGenetics} defaultExpanded={false} />
</div>
)}
{step === 3 && (
<div>
{error && <p className="mb-4 font-body text-sm text-danger">{error}</p>}
{error && (
<p role="alert" className="mb-4 font-body text-sm text-danger">
{error}
</p>
)}
<ReviewStep
details={details}
pedigreeSummary={pedigreeSummary}
genetics={genetics}
onBack={() => setStep(2)}
onSave={handleSave}
saving={saving}
weightUnit={settings?.weight_unit}
onEdit={goToStep}
/>
</div>
)}
</div>
<StickyActionBar>
{step > 1 && (
<Button type="button" variant="secondary" onClick={() => goToStep(step - 1)} disabled={saving}>
Back
</Button>
)}
{step === 1 && (
<Button type="button" onClick={() => goToStep(2)}>
Next: Details
</Button>
)}
{step === 2 && (
<Button type="button" onClick={handleNextFromDetails}>
Next: Review
</Button>
)}
{step === 3 && (
<Button type="button" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Rabbit'}
</Button>
)}
</StickyActionBar>
</div>
)
}

View File

@ -104,6 +104,7 @@ function EditRabbit() {
value={genetics}
onChange={updateGenetics}
defaultExpanded={hasAnyLocusSet(genetics)}
onUseColor={(color) => updateDetails({ color_name: color })}
/>
{error && <p className="font-body text-sm text-danger">{error}</p>}

View File

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Link, useLocation, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
@ -140,6 +140,9 @@ function LitterHistory({ rabbitId }) {
function RabbitDetail() {
const { id } = useParams()
const rabbitId = Number(id)
// One-off message handed over by the page that navigated here (e.g. Add
// Rabbit when the photo upload failed after the rabbit was created).
const notice = useLocation().state?.notice
const queryClient = useQueryClient()
const [updating, setUpdating] = useState(false)
const [error, setError] = useState(null)
@ -231,6 +234,11 @@ function RabbitDetail() {
/>
<div className="space-y-6">
{notice && (
<p role="status" className="rounded-md border border-accent-warm bg-surface-raised px-4 py-3 font-body text-sm text-text-primary">
{notice}
</p>
)}
{error && <p className="font-body text-sm text-danger">{error}</p>}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">