Compare commits
No commits in common. "5d29e2b37003acb9d377a860300bc9c749efee10" and "afff8388b092cb43adcf99625e19f872a588d213" have entirely different histories.
5d29e2b370
...
afff8388b0
|
|
@ -62,12 +62,10 @@ 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/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/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/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. **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/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. 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/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. Optional `onUseColor(color)` adds a "Use as color name" button (Add + Edit wire it to `details.color_name`). |
|
| `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. 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/ReviewStep.jsx` | Read-only summary (Step 7.4) of pedigree/details/genetics before save, with a shared `dash()` helper for empty values. |
|
||||||
| `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/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/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). |
|
| `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). |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
To serve on a different host port, set `MYKINDLEBOX_PORT` (e.g. in a `.env` file
|
||||||
next to `docker-compose.yml`):
|
next to `docker-compose.yml`, alongside `MYKINDLEBOX_PASSWORD`):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
MYKINDLEBOX_PORT=3000
|
MYKINDLEBOX_PORT=3000
|
||||||
|
|
@ -100,51 +100,6 @@ 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
|
8080. (Only the host side changes; inside the compose network nginx still
|
||||||
listens on 80.)
|
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
|
### Authentication
|
||||||
|
|
||||||
The app uses **user accounts** — everyone shares the same herd; accounts are
|
The app uses **user accounts** — everyone shares the same herd; accounts are
|
||||||
|
|
@ -161,9 +116,4 @@ last 30 days, surviving server restarts.
|
||||||
|
|
||||||
The herd database (`server/data/`), uploaded photos, and `.env` files are
|
The herd database (`server/data/`), uploaded photos, and `.env` files are
|
||||||
**gitignored** — they are never committed. In production they persist in the
|
**gitignored** — they are never committed. In production they persist in the
|
||||||
`server-data` volume; back that volume up to preserve your data. A quick
|
`server-data` volume; back that volume up to preserve your data.
|
||||||
snapshot from a running stack:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose cp server:/app/data ./data-backup-$(date +%F)
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { STATUS_LABELS } from '../lib/format'
|
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
const STATUS_CLASSES = {
|
||||||
active: 'bg-accent text-surface',
|
active: 'bg-accent text-surface',
|
||||||
for_sale: 'bg-accent-warm text-surface',
|
for_sale: 'bg-accent-warm text-surface',
|
||||||
|
|
@ -9,6 +7,15 @@ const STATUS_CLASSES = {
|
||||||
other: 'bg-border text-text-primary',
|
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 }) {
|
function Badge({ status }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { LOCI, generateColorString } from '../lib/genetics'
|
import { LOCI, generateColorString } from '../lib/genetics'
|
||||||
|
|
||||||
function ChevronIcon({ open }) {
|
function GeneticsForm({ value, onChange, defaultExpanded = false }) {
|
||||||
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 [expanded, setExpanded] = useState(defaultExpanded)
|
||||||
const colorString = generateColorString(value)
|
const colorString = generateColorString(value)
|
||||||
|
|
||||||
|
|
@ -31,26 +14,24 @@ function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor })
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
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"
|
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>Genetics (optional)</span>
|
||||||
<ChevronIcon open={expanded} />
|
<span className="text-text-secondary">{expanded ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="space-y-3 border-t border-border px-4 py-4">
|
<div className="space-y-3 border-t border-border px-4 py-4">
|
||||||
{LOCI.map((locus) => (
|
{LOCI.map((locus) => (
|
||||||
<div key={locus.key} className="flex flex-col gap-1.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
|
<div key={locus.key} className="flex flex-wrap items-center gap-2">
|
||||||
<span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span>
|
<span className="w-20 shrink-0 font-body text-sm text-text-secondary">{locus.label}</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{locus.options.map((opt) => (
|
{locus.options.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={value[locus.key] === opt.value}
|
|
||||||
onClick={() => setLocus(locus.key, opt.value)}
|
onClick={() => setLocus(locus.key, opt.value)}
|
||||||
className={`rounded-full border px-3 py-1.5 font-body text-sm md:py-1 md:text-xs ${
|
className={`rounded-full border px-3 py-1 font-body text-xs ${
|
||||||
value[locus.key] === opt.value
|
value[locus.key] === opt.value
|
||||||
? 'border-accent bg-accent text-surface'
|
? 'border-accent bg-accent text-surface'
|
||||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||||
|
|
@ -67,25 +48,13 @@ function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor })
|
||||||
<label className="block font-body text-sm font-medium text-text-primary">
|
<label className="block font-body text-sm font-medium text-text-primary">
|
||||||
Generated color string
|
Generated color string
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex gap-2">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
readOnly
|
||||||
readOnly
|
value={colorString}
|
||||||
value={colorString}
|
placeholder="Select loci to generate a description"
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { searchRabbits, getRabbitAncestors } from '../api'
|
import { searchRabbits, getRabbitAncestors } from '../api'
|
||||||
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
||||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
|
||||||
import {
|
import {
|
||||||
ALL_SLOTS,
|
|
||||||
GEN1,
|
GEN1,
|
||||||
GEN2,
|
GEN2,
|
||||||
GEN3,
|
GEN3,
|
||||||
|
|
@ -23,32 +21,6 @@ const EMPTY_MANUAL_FORM = { name: '', farm: '', ear_number: '', weight_lbs: '0',
|
||||||
// mounts, so the very first click is positioned correctly.
|
// mounts, so the very first click is positioned correctly.
|
||||||
const POPUP_MAX_HEIGHT = 360
|
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`
|
// `dob` on the manual-ancestor form is a real date input value; `dobUnknown`
|
||||||
// is the separate "Unknown" checkbox. They collapse into one stored string
|
// is the separate "Unknown" checkbox. They collapse into one stored string
|
||||||
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
|
// ("Unknown" or an actual date) only at save time, via serializeManualAncestor.
|
||||||
|
|
@ -83,7 +55,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
function updateDropUp() {
|
function updateDropUp() {
|
||||||
if (!triggerRef.current) return
|
if (!triggerRef.current) return
|
||||||
const rect = triggerRef.current.getBoundingClientRect()
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
const spaceBelow = window.innerHeight - bottomChromeHeight() - rect.bottom
|
const spaceBelow = window.innerHeight - rect.bottom
|
||||||
const spaceAbove = rect.top
|
const spaceAbove = rect.top
|
||||||
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
|
setDropUp(spaceBelow < POPUP_MAX_HEIGHT + 8 && spaceAbove > spaceBelow)
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +102,9 @@ 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-xs text-text-secondary">{label}</p>
|
||||||
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
|
<p className="truncate font-body text-sm text-text-secondary italic">{text}</p>
|
||||||
</div>
|
</div>
|
||||||
<LockIcon />
|
<span aria-label="locked" className="text-text-secondary">
|
||||||
|
🔒
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +166,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
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 ${
|
className={`absolute left-0 z-10 w-64 rounded-md border border-border bg-surface-raised p-2 shadow-sm ${
|
||||||
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -200,14 +174,14 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('search')}
|
onClick={() => setMode('search')}
|
||||||
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'}`}
|
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'search' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
||||||
>
|
>
|
||||||
Search existing
|
Search existing
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('manual')}
|
onClick={() => setMode('manual')}
|
||||||
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'}`}
|
className={`flex-1 rounded px-2 py-1 text-xs ${mode === 'manual' ? 'bg-accent text-surface' : 'bg-surface-sunken text-text-secondary'}`}
|
||||||
>
|
>
|
||||||
Enter manually
|
Enter manually
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -223,7 +197,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
|
onKeyDown={(e) => handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })}
|
||||||
placeholder="Search by name…"
|
placeholder="Search by name…"
|
||||||
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"
|
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
<ul className="mt-1 max-h-40 overflow-auto">
|
<ul className="mt-1 max-h-40 overflow-auto">
|
||||||
{results.map((rabbit, index) => (
|
{results.map((rabbit, index) => (
|
||||||
|
|
@ -239,7 +213,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
close,
|
close,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
|
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-surface-sunken"
|
||||||
>
|
>
|
||||||
{rabbit.name}
|
{rabbit.name}
|
||||||
{rabbit.farm ? ` (${rabbit.farm})` : ''}
|
{rabbit.farm ? ` (${rabbit.farm})` : ''}
|
||||||
|
|
@ -261,21 +235,21 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
value={manualForm.farm}
|
value={manualForm.farm}
|
||||||
onChange={(e) => setManualField('farm', e.target.value)}
|
onChange={(e) => setManualField('farm', e.target.value)}
|
||||||
placeholder="Farm Name *"
|
placeholder="Farm Name *"
|
||||||
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"
|
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={manualForm.name}
|
value={manualForm.name}
|
||||||
onChange={(e) => setManualField('name', e.target.value)}
|
onChange={(e) => setManualField('name', e.target.value)}
|
||||||
placeholder="Rabbit Name *"
|
placeholder="Rabbit Name *"
|
||||||
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"
|
className="w-full rounded border border-border px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={manualForm.ear_number}
|
value={manualForm.ear_number}
|
||||||
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
|
onChange={(e) => setManualField('ear_number', e.target.value.toUpperCase())}
|
||||||
placeholder="Ear Number *"
|
placeholder="Ear Number *"
|
||||||
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"
|
className="w-full rounded border border-border px-2 py-1 text-sm uppercase focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
|
<label className="block px-0.5 font-body text-xs text-text-secondary">Weight (lbs)</label>
|
||||||
|
|
@ -284,7 +258,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={manualForm.weight_lbs}
|
value={manualForm.weight_lbs}
|
||||||
onChange={(e) => setManualField('weight_lbs', e.target.value)}
|
onChange={(e) => setManualField('weight_lbs', e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -294,7 +268,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
value={manualForm.dob}
|
value={manualForm.dob}
|
||||||
disabled={manualForm.dobUnknown}
|
disabled={manualForm.dobUnknown}
|
||||||
onChange={(e) => setManualField('dob', e.target.value)}
|
onChange={(e) => setManualField('dob', e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
|
<label className="flex items-center gap-1.5 px-0.5 font-body text-xs text-text-secondary">
|
||||||
|
|
@ -316,7 +290,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
type="button"
|
type="button"
|
||||||
onClick={saveManual}
|
onClick={saveManual}
|
||||||
disabled={!canSaveManual}
|
disabled={!canSaveManual}
|
||||||
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"
|
className="w-full rounded bg-accent px-2 py-1 text-xs text-surface disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -326,7 +300,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={markUnknown}
|
onClick={markUnknown}
|
||||||
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"
|
className="mt-2 w-full rounded px-2 py-1 text-left text-xs text-text-secondary hover:bg-surface-sunken"
|
||||||
>
|
>
|
||||||
Mark as Unknown
|
Mark as Unknown
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -355,29 +329,6 @@ 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({
|
function PedigreeTree({
|
||||||
subjectName = 'New Rabbit',
|
subjectName = 'New Rabbit',
|
||||||
initialState,
|
initialState,
|
||||||
|
|
@ -390,7 +341,6 @@ function PedigreeTree({
|
||||||
// sets state synchronously (it's called from a mount effect below). An id is
|
// 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.
|
// never removed: on failure the cache stores null, and we don't retry.
|
||||||
const cascadeFetchStarted = useRef(new Set())
|
const cascadeFetchStarted = useRef(new Set())
|
||||||
const wide = useMediaQuery(WIDE_TREE_QUERY)
|
|
||||||
|
|
||||||
function setSlotState(slot, value) {
|
function setSlotState(slot, value) {
|
||||||
setState((prev) => ({ ...prev, [slot]: value }))
|
setState((prev) => ({ ...prev, [slot]: value }))
|
||||||
|
|
@ -420,30 +370,19 @@ function PedigreeTree({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!wide) {
|
|
||||||
const slotProps = { state, setSlotState, cascadeCache, fetchCascade }
|
|
||||||
return (
|
|
||||||
<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 (
|
return (
|
||||||
<div className="flex w-full items-center gap-6 p-4 pb-[300px]">
|
// The tree needs ~900px; on narrower screens the outer wrapper scrolls
|
||||||
<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">
|
// sideways rather than the min-width stretching the whole page.
|
||||||
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
<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 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>
|
||||||
|
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||||
|
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||||
|
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||||
</div>
|
</div>
|
||||||
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
|
||||||
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
|
||||||
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ const SEX_OPTIONS = [
|
||||||
{ value: 'unknown', label: 'Unknown' },
|
{ value: 'unknown', label: 'Unknown' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const INPUT_CLASS =
|
|
||||||
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
|
|
||||||
const LABEL_CLASS = 'block font-body text-sm font-medium text-text-primary'
|
|
||||||
|
|
||||||
function lbsToOz(decimalLbs) {
|
function lbsToOz(decimalLbs) {
|
||||||
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
|
if (decimalLbs === null || decimalLbs === undefined || decimalLbs === '') return { lbs: '', oz: '' }
|
||||||
const total = Number(decimalLbs)
|
const total = Number(decimalLbs)
|
||||||
|
|
@ -35,26 +31,8 @@ function ozToLbs(lbs, oz) {
|
||||||
return l + o / 16
|
return l + o / 16
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fields most people fill in when adding a rabbit come first; the rest
|
function RabbitDetailsForm({ value, onChange, onPhotoChange, weightUnit = 'decimal_lbs', showValidation = false }) {
|
||||||
// (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 [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)
|
const { lbs: ozLbs, oz: ozOz } = lbsToOz(value.weight_lbs)
|
||||||
|
|
||||||
function setField(field, fieldValue) {
|
function setField(field, fieldValue) {
|
||||||
|
|
@ -71,217 +49,179 @@ function RabbitDetailsForm({
|
||||||
const nameInvalid = showValidation && !value.name?.trim()
|
const nameInvalid = showValidation && !value.name?.trim()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="md:col-span-2">
|
||||||
<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>
|
||||||
Name <span className="text-danger">*</span>
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
id={nameInputId}
|
value={value.name ?? ''}
|
||||||
type="text"
|
onChange={(e) => setField('name', e.target.value)}
|
||||||
value={value.name ?? ''}
|
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 ${
|
||||||
onChange={(e) => setField('name', e.target.value)}
|
nameInvalid ? 'border-danger' : 'border-border'
|
||||||
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 && <p className="mt-1 font-body text-xs text-danger">Name is required</p>}
|
||||||
nameInvalid ? 'border-danger' : 'border-border'
|
</div>
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{nameInvalid && (
|
|
||||||
<p id={`${nameInputId}-error`} className="mt-1 font-body text-xs text-danger">
|
|
||||||
Give this rabbit a name to continue.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>Farm / Prefix</label>
|
<label className="block font-body text-sm font-medium text-text-primary">Farm / Prefix</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value.farm ?? ''}
|
value={value.farm ?? ''}
|
||||||
onChange={(e) => setField('farm', e.target.value)}
|
onChange={(e) => setField('farm', e.target.value)}
|
||||||
className={INPUT_CLASS}
|
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>Ear Number</label>
|
<label className="block font-body text-sm font-medium text-text-primary">Sex</label>
|
||||||
<input
|
<div className="mt-1 flex gap-1">
|
||||||
type="text"
|
{SEX_OPTIONS.map((opt) => (
|
||||||
value={value.ear_number ?? ''}
|
<button
|
||||||
onChange={(e) => setField('ear_number', e.target.value.toUpperCase())}
|
key={opt.value}
|
||||||
className={`${INPUT_CLASS} uppercase`}
|
type="button"
|
||||||
/>
|
onClick={() => setField('sex', opt.value)}
|
||||||
</div>
|
className={`flex-1 rounded-md border px-3 py-2 font-body text-sm ${
|
||||||
|
value.sex === opt.value
|
||||||
<div>
|
? 'border-accent bg-accent text-surface'
|
||||||
<span className={LABEL_CLASS}>Sex</span>
|
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||||
<div className="mt-1 flex gap-1" role="group" aria-label="Sex">
|
}`}
|
||||||
{SEX_OPTIONS.map((opt) => (
|
>
|
||||||
<button
|
{opt.label}
|
||||||
key={opt.value}
|
</button>
|
||||||
type="button"
|
))}
|
||||||
aria-pressed={value.sex === opt.value}
|
|
||||||
onClick={() => setField('sex', opt.value)}
|
|
||||||
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-0 ${
|
|
||||||
value.sex === opt.value
|
|
||||||
? 'border-accent bg-accent text-surface'
|
|
||||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<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="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={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={INPUT_CLASS}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
{photoPreview && (
|
|
||||||
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={collapseSecondary ? 'rounded-lg border border-border' : ''}>
|
<div>
|
||||||
{collapseSecondary && (
|
<label className="block font-body text-sm font-medium text-text-primary">Date of Birth</label>
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="date"
|
||||||
onClick={() => setMoreOpen((v) => !v)}
|
value={value.dob ?? ''}
|
||||||
aria-expanded={moreOpen}
|
onChange={(e) => setField('dob', e.target.value)}
|
||||||
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="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"
|
||||||
>
|
/>
|
||||||
<span>More details</span>
|
</div>
|
||||||
<span className="font-normal text-text-secondary">Status, location, dates</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{moreOpen && (
|
<div>
|
||||||
<div
|
<label className="block font-body text-sm font-medium text-text-primary">Acquired Date</label>
|
||||||
className={`grid grid-cols-1 gap-4 md:grid-cols-2 ${
|
<input
|
||||||
collapseSecondary ? 'border-t border-border px-4 py-4' : ''
|
type="date"
|
||||||
}`}
|
value={value.acquired_date ?? ''}
|
||||||
>
|
onChange={(e) => setField('acquired_date', e.target.value || null)}
|
||||||
<div>
|
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"
|
||||||
<label className={LABEL_CLASS}>Status</label>
|
/>
|
||||||
<select
|
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
|
||||||
value={value.status ?? 'active'}
|
</div>
|
||||||
onChange={(e) => setField('status', e.target.value)}
|
|
||||||
className={`${INPUT_CLASS} bg-surface-raised`}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>Location</label>
|
<label className="block font-body text-sm font-medium text-text-primary">Death Date</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="date"
|
||||||
value={value.location ?? ''}
|
value={value.death_date ?? ''}
|
||||||
onChange={(e) => setField('location', e.target.value)}
|
onChange={(e) => setField('death_date', e.target.value || null)}
|
||||||
placeholder="Cage or hutch"
|
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>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>Acquired Date</label>
|
<label className="block font-body text-sm font-medium text-text-primary">Weight</label>
|
||||||
<input
|
{weightUnit === 'lbs_oz' ? (
|
||||||
type="date"
|
<div className="mt-1 flex gap-2">
|
||||||
value={value.acquired_date ?? ''}
|
<input
|
||||||
onChange={(e) => setField('acquired_date', e.target.value || null)}
|
type="number"
|
||||||
className={INPUT_CLASS}
|
min="0"
|
||||||
/>
|
value={ozLbs}
|
||||||
<p className="mt-1 font-body text-xs text-text-secondary">If added to the herd after birth</p>
|
onChange={(e) => setField('weight_lbs', ozToLbs(e.target.value, ozOz))}
|
||||||
</div>
|
placeholder="lbs"
|
||||||
|
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>
|
/>
|
||||||
<label className={LABEL_CLASS}>Death Date</label>
|
<input
|
||||||
<input
|
type="number"
|
||||||
type="date"
|
min="0"
|
||||||
value={value.death_date ?? ''}
|
max="15"
|
||||||
onChange={(e) => setField('death_date', e.target.value || null)}
|
value={ozOz}
|
||||||
className={INPUT_CLASS}
|
onChange={(e) => setField('weight_lbs', ozToLbs(ozLbs, e.target.value))}
|
||||||
/>
|
placeholder="oz"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-sm font-medium text-text-primary">Ear Number</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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<label className="block font-body text-sm font-medium text-text-primary">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"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-body text-sm font-medium text-text-primary">Color Name</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block font-body text-sm font-medium text-text-primary">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"
|
||||||
|
/>
|
||||||
|
{photoPreview && (
|
||||||
|
<img src={photoPreview} alt="Preview" className="mt-2 h-24 w-24 rounded-md object-cover" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,74 @@
|
||||||
|
import Button from './Button'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import { LOCI, generateColorString } from '../lib/genetics'
|
import { LOCI, generateColorString } from '../lib/genetics'
|
||||||
import { SEX_LABELS, STATUS_LABELS, formatDate, formatWeight } from '../lib/format'
|
|
||||||
|
|
||||||
// Display order + formatting for the Details card. Empty values are skipped
|
const DETAIL_FIELDS = [
|
||||||
// (except Name, which is required), so the review shows what was entered
|
{ key: 'name', label: 'Name' },
|
||||||
// rather than a wall of dashes.
|
{ key: 'farm', label: 'Farm / Prefix' },
|
||||||
function detailRows(details, weightUnit) {
|
{ key: 'sex', label: 'Sex' },
|
||||||
return [
|
{ key: 'dob', label: 'Date of Birth' },
|
||||||
['Name', details.name],
|
{ key: 'weight_lbs', label: 'Weight (lbs)' },
|
||||||
['Farm / Prefix', details.farm],
|
{ key: 'ear_number', label: 'Ear Number' },
|
||||||
['Ear Number', details.ear_number],
|
{ key: 'location', label: 'Location' },
|
||||||
['Sex', SEX_LABELS[details.sex] ?? details.sex],
|
{ key: 'status', label: 'Status' },
|
||||||
['Date of Birth', formatDate(details.dob)],
|
{ key: 'breed', label: 'Breed' },
|
||||||
['Breed', details.breed],
|
{ key: 'color_name', label: 'Color Name' },
|
||||||
['Color Name', details.color_name],
|
]
|
||||||
['Weight', formatWeight(details.weight_lbs, weightUnit)],
|
|
||||||
['Status', STATUS_LABELS[details.status] ?? details.status],
|
function dash(value) {
|
||||||
['Location', details.location],
|
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||||
['Acquired', formatDate(details.acquired_date)],
|
|
||||||
['Death Date', formatDate(details.death_date)],
|
|
||||||
].filter(([label, value]) => label === 'Name' || (value !== null && value !== undefined && value !== ''))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function locusLabel(locus, value) {
|
function ReviewStep({ details, pedigreeSummary, genetics, onBack, onSave, saving = false }) {
|
||||||
return locus.options.find((opt) => opt.value === value)?.label ?? value
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, onEdit, children }) {
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<div className="space-y-6">
|
||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
<Card className="p-4">
|
||||||
<h3 className="font-display text-lg font-bold text-text-primary">{title}</h3>
|
<h3 className="mb-3 font-display text-lg text-text-primary">Pedigree</h3>
|
||||||
{onEdit && (
|
<dl className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
<button
|
{pedigreeSummary.map((node) => (
|
||||||
type="button"
|
<div key={node.slot} className="flex justify-between gap-2 border-b border-border py-1">
|
||||||
onClick={onEdit}
|
<dt className="font-body text-sm text-text-secondary">{node.label}</dt>
|
||||||
className="-my-2 rounded-md px-3 py-2 font-body text-sm font-medium text-accent hover:bg-surface-sunken"
|
<dd className="font-body text-sm text-text-primary">{dash(node.display)}</dd>
|
||||||
>
|
</div>
|
||||||
Edit
|
))}
|
||||||
</button>
|
</dl>
|
||||||
)}
|
</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>
|
||||||
|
</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>
|
||||||
|
<p className="mt-3 font-body text-sm text-text-secondary">
|
||||||
|
Color: <span className="text-text-primary">{dash(generateColorString(genetics))}</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>
|
</div>
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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">{colorString}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// 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
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -4,15 +4,6 @@ export const SEX_LABELS = {
|
||||||
unknown: 'Unknown',
|
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) {
|
export function formatAge(dob) {
|
||||||
if (!dob) return 'Age unknown'
|
if (!dob) return 'Age unknown'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
@ -6,13 +6,11 @@ import PedigreeTree from '../components/PedigreeTree'
|
||||||
import RabbitDetailsForm from '../components/RabbitDetailsForm'
|
import RabbitDetailsForm from '../components/RabbitDetailsForm'
|
||||||
import GeneticsForm from '../components/GeneticsForm'
|
import GeneticsForm from '../components/GeneticsForm'
|
||||||
import ReviewStep from '../components/ReviewStep'
|
import ReviewStep from '../components/ReviewStep'
|
||||||
import StickyActionBar from '../components/StickyActionBar'
|
|
||||||
import Button from '../components/Button'
|
import Button from '../components/Button'
|
||||||
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
|
import { getSettings, getRabbit, createRabbit, uploadRabbitPhoto } from '../api'
|
||||||
import { createInitialPedigreeState } from '../lib/pedigree'
|
import { createInitialPedigreeState } from '../lib/pedigree'
|
||||||
|
|
||||||
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
|
const STEP_LABELS = ['Pedigree', 'Details', 'Review']
|
||||||
const NAME_INPUT_ID = 'add-rabbit-name'
|
|
||||||
|
|
||||||
const INITIAL_DETAILS = {
|
const INITIAL_DETAILS = {
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -37,54 +35,14 @@ const INITIAL_GENETICS = {
|
||||||
locus_v: null,
|
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() {
|
function AddRabbit() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const sireId = searchParams.get('sire')
|
const sireId = searchParams.get('sire')
|
||||||
const damId = searchParams.get('dam')
|
const damId = searchParams.get('dam')
|
||||||
const prefillDob = searchParams.get('dob')
|
const prefillDob = searchParams.get('dob')
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
const [details, setDetails] = useState(() => ({
|
const [details, setDetails] = useState(() => ({
|
||||||
...INITIAL_DETAILS,
|
...INITIAL_DETAILS,
|
||||||
dob: prefillDob ?? INITIAL_DETAILS.dob,
|
dob: prefillDob ?? INITIAL_DETAILS.dob,
|
||||||
|
|
@ -96,35 +54,6 @@ function AddRabbit() {
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [detailsDefaulted, setDetailsDefaulted] = useState(false)
|
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: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||||
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
|
const { data: sireRabbit, isLoading: sireLoading } = useQuery({
|
||||||
|
|
@ -173,145 +102,85 @@ function AddRabbit() {
|
||||||
setGenetics((prev) => ({ ...prev, ...partial }))
|
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() {
|
async function handleSave() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
let created
|
|
||||||
try {
|
try {
|
||||||
created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
|
const created = await createRabbit({ ...details, ...pedigreeFields, ...genetics })
|
||||||
|
if (photoFile) {
|
||||||
|
await uploadRabbitPhoto(created.id, photoFile)
|
||||||
|
}
|
||||||
|
navigate(`/herd/${created.id}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setSaving(false)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navigate(`/herd/${created.id}`, { replace: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader title="Add Rabbit" />
|
<PageHeader
|
||||||
|
title="Add Rabbit"
|
||||||
|
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
|
||||||
|
action={headerAction}
|
||||||
|
/>
|
||||||
|
|
||||||
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
|
<div>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className={step === 2 ? 'space-y-6' : 'hidden'}>
|
|
||||||
<RabbitDetailsForm
|
|
||||||
value={details}
|
|
||||||
onChange={updateDetails}
|
|
||||||
onPhotoChange={setPhotoFile}
|
|
||||||
weightUnit={settings?.weight_unit}
|
|
||||||
showValidation={triedNext}
|
|
||||||
collapseSecondary
|
|
||||||
nameInputId={NAME_INPUT_ID}
|
|
||||||
/>
|
|
||||||
<GeneticsForm
|
|
||||||
value={genetics}
|
|
||||||
onChange={updateGenetics}
|
|
||||||
defaultExpanded={false}
|
|
||||||
onUseColor={(color) => updateDetails({ color_name: color })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div>
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="mb-4 font-body text-sm text-danger">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ReviewStep
|
|
||||||
details={details}
|
|
||||||
pedigreeSummary={pedigreeSummary}
|
|
||||||
genetics={genetics}
|
|
||||||
weightUnit={settings?.weight_unit}
|
|
||||||
onEdit={goToStep}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StickyActionBar>
|
|
||||||
{step > 1 && (
|
|
||||||
<Button type="button" variant="secondary" onClick={() => goToStep(step - 1)} disabled={saving}>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Button type="button" onClick={() => goToStep(2)}>
|
<PedigreeTree
|
||||||
Next: Details
|
initialState={pedigreeInitialState}
|
||||||
</Button>
|
onChange={(fields, summary) => {
|
||||||
|
setPedigreeFields(fields)
|
||||||
|
setPedigreeSummary(summary)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<Button type="button" onClick={handleNextFromDetails}>
|
<div className="space-y-6">
|
||||||
Next: Review
|
<RabbitDetailsForm
|
||||||
</Button>
|
value={details}
|
||||||
|
onChange={updateDetails}
|
||||||
|
onPhotoChange={setPhotoFile}
|
||||||
|
weightUnit={settings?.weight_unit}
|
||||||
|
showValidation={!step2Valid}
|
||||||
|
/>
|
||||||
|
<GeneticsForm value={genetics} onChange={updateGenetics} defaultExpanded={false} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<Button type="button" onClick={handleSave} disabled={saving}>
|
<div>
|
||||||
{saving ? 'Saving…' : 'Save Rabbit'}
|
{error && <p className="mb-4 font-body text-sm text-danger">{error}</p>}
|
||||||
</Button>
|
<ReviewStep
|
||||||
|
details={details}
|
||||||
|
pedigreeSummary={pedigreeSummary}
|
||||||
|
genetics={genetics}
|
||||||
|
onBack={() => setStep(2)}
|
||||||
|
onSave={handleSave}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</StickyActionBar>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,6 @@ function EditRabbit() {
|
||||||
value={genetics}
|
value={genetics}
|
||||||
onChange={updateGenetics}
|
onChange={updateGenetics}
|
||||||
defaultExpanded={hasAnyLocusSet(genetics)}
|
defaultExpanded={hasAnyLocusSet(genetics)}
|
||||||
onUseColor={(color) => updateDetails({ color_name: color })}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import PageHeader from '../components/PageHeader'
|
import PageHeader from '../components/PageHeader'
|
||||||
import Card from '../components/Card'
|
import Card from '../components/Card'
|
||||||
|
|
@ -140,9 +140,6 @@ function LitterHistory({ rabbitId }) {
|
||||||
function RabbitDetail() {
|
function RabbitDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const rabbitId = Number(id)
|
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 queryClient = useQueryClient()
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
@ -234,11 +231,6 @@ function RabbitDetail() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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>}
|
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue