Confetti redesign: new theme system, layout and dashboard schedule
Rework the client's look and several flows around the "Confetti" design direction: - Themes are now palette x mode (Confetti / Meadow in Auto, Light, Dark), with six semantic party hues, Bricolage Grotesque / Figtree / JetBrains Mono, rounder radii and card/pop shadows. Legacy theme ids are normalized. - Floating sidebar with colored section tiles and a theme toggle; floating pill bottom nav on phones. - Dashboard: today-led hero, colored stat tiles, a derived "Coming up" schedule (palpate, nest box, due, wean, birthdays), gestation tracker, recent litters with kit dots and a for-sale strip. - Coat-colored lop placeholders for rabbits without photos. - Bracket pedigrees (sire side blue, dam side pink) on rabbit detail and in Add Rabbit, plus one-tap "Quick pick" suggested parents. - Rabbit detail hero with fact tiles; litter milestone strip; restyled herd, litters, settings, auth screens and shared controls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
91fa03b794
commit
6d8802af41
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{ "name": "server", "runtimeExecutable": "npm", "runtimeArgs": ["--prefix", "mykindlebox/server", "run", "dev"], "port": 3001 },
|
||||
{ "name": "client", "runtimeExecutable": "npm", "runtimeArgs": ["--prefix", "mykindlebox/client", "run", "dev"], "port": 5173 }
|
||||
]
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
|---|---|
|
||||
| `src/App.jsx` | Root component. Wraps the app in `QueryClientProvider` (React Query) and `BrowserRouter`, defining every IA route nested under a single `Layout` layout-route (`<Route element={<Layout />}>`) so the sidebar/bottom-nav persists across page navigation. Includes a catch-all `*` → `NotFound` route. |
|
||||
| `src/api.js` | Plain JS (not `.ts` — the client is deliberately plain JS/JSX per Step 1.3, not TypeScript), one fetch function per backend endpoint (rabbits CRUD/search/quick-search/ancestors/photo, litters CRUD, events CRUD (`getEvents`/`createEvent`/`updateEvent`/`deleteEvent`), settings, stats, export, `importData` which POSTs a `FormData` file to `/api/import`, shows (`getArbaShows`/`refreshArbaShows`), show events (`getShowEvents`/`getShowEvent`/`createShowEvent`/`updateShowEvent`/`deleteShowEvent`/`addShowToEvent`/`removeShowFromEvent`), show entries + report (`addShowEntry`/`updateShowEntry`/`removeShowEntry`/`getEventReport`), judges + placements (`addShowJudge`/`updateShowJudge`/`removeShowJudge`/`setShowEntryPlacement`)), all targeting `http://localhost:3001/api`. Shared `request()` helper throws on non-2xx (surfacing the JSON `{ error }` body's message) and only sets `Content-Type: application/json` when the body is a JSON string, so the multipart `FormData` photo-upload call isn't given the wrong header. |
|
||||
| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's `<Outlet />`. Desktop: 240px left sidebar (`hidden md:flex`) with `NavLink`s for Dashboard/Herd/Litters/Genetics/Shows/Settings. Mobile (`≤768px`): fixed-bottom nav (`md:hidden`, icon + label, 56px tall, padded by `env(safe-area-inset-bottom)`) with Dashboard/Herd/Add (→ `/herd/add`)/Litters/More — "More" opens a small menu for Genetics/Shows/Settings so every page is reachable on a phone. The sidebar is `sticky` and the content column is `min-w-0` + `max-w-6xl`, so wide children (tables, the 900px pedigree tree) scroll inside their own containers instead of stretching the page. `<main>` owns the page gutter (`px-4 md:px-8`); pages must not add their own horizontal padding. Active-route styling comes from `NavLink`'s `isActive` callback, not manual path matching. Also renders `<QuickSearch />` at the top of the `<main>` content area (above `<Outlet />`) on every page **except** `/herd` (which has its own search box) and the add/edit form routes (`showsQuickSearch`), where jumping to a result would silently discard the unsaved form. |
|
||||
| `src/components/Layout.jsx` | Persistent app shell rendered via React Router's `<Outlet />` (Confetti redesign, 2026-11-01). Desktop (`md+`): a **floating sidebar** — a sticky rounded panel inset 16px from the viewport (`h-[calc(100vh-2rem)]`) with the brand mark + rabbitry name, a primary "Add rabbit" pill, `NavLink`s each with a colored icon tile (each section keeps its hue: Dashboard yellow `c5`, Herd violet `c1` + live active-herd count from `/api/stats`, Litters orange `c3`, Shows teal `c4`, Settings blue `c6`), and a footer with the user's avatar, app version, `ThemeToggle` and sign-out. Mobile: a brand row at the top of `<main>` and a **floating pill bottom nav** (`fixed inset-x-3`, above `env(safe-area-inset-bottom)`) with Home/Herd/Add (filled accent tab → `/herd/add`)/Litters/More — More opens a menu with Shows/Settings, the version and a `ThemeToggle`. The content column is `min-w-0` + `max-w-[1180px]`; `<main>` owns the page gutter (`px-4 md:px-10`) and the bottom padding that clears the floating nav (`pb-[calc(7rem+safe-area)]`); pages must not add their own horizontal padding. Herd's active state is computed (`/herd*` except `/herd/add`). Also renders `<QuickSearch />` above `<Outlet />` on every page **except** `/herd` and the add/edit form routes (`showsQuickSearch`). |
|
||||
| `src/components/QuickSearch.jsx` | Global quick-search bar (top of every page via `Layout`). A debounced (200ms) text input + a "Filters" popover of four checkboxes (Farm Name / Rabbit Name / Ear Number / Color), **all selected by default**, controlling which fields `GET /api/rabbits/quick-search` prefix-matches. Results render in a dropdown as the user types; each row shows name · farm plus a chip per `matched_fields` entry (value-bearing chips for ear/color, e.g. `Ear #: A15`). Selecting a result navigates to `/herd/:id`. Results outside the active herd show a status `Badge` (pedigree-materialized ancestors are status `other`). Reuses `lib/keyboardNav.js`'s `handleSearchInputKeyDown`/`handleResultKeyDown` for the same `<ul><li><button>` roving-focus dropdown shape as `RabbitAutocomplete`/`PedigreeTree`. The debounce effect deliberately does **not** call `setState` synchronously (project lint forbids it) — it early-returns on empty query / no filters, and the dropdown render is gated on a non-empty query so stale results never show. |
|
||||
| `src/components/Button.jsx` | `variant` (`primary`/`secondary`/`danger`) × `size` (`sm`/`md`) class-map component. With a `to` prop it renders a router `<Link>` with the same classes — **use `<Button to=…>` for navigation, never `<Link><Button>`** (a button inside a link is invalid HTML and adds a second tab stop). |
|
||||
| `src/components/LoadError.jsx` | Failed-fetch state for a page's main record (`title`, `error`, `backTo`, `backLabel`); says "not found" for 404s. Detail/edit pages render it on `isError` so a stale link never sits on "Loading…" forever. |
|
||||
|
|
@ -62,7 +62,10 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
| `src/pages/ShowEvents.jsx` + `src/pages/ShowEventDetail.jsx` | The Shows feature Phase 2 event UI. `ShowEvents.jsx` (`/shows/events`) lists events as cards (name, date range, show count) over `getShowEvents`, with an inline `NewEventForm` toggled by "New Event". `ShowEventDetail.jsx` (`/shows/events/:id`) shows the event header (inline `EditEventForm` for rename/notes, Delete with a `window.confirm`, "Add Shows" link back to `/shows`) and the **Shows tab** (the only tab in Phase 2 — judges/entries/placements come later): each `ShowCard` renders the snapshot (club, date, type, sanction #, location, secretary, mailto/map/catalog links) with a Remove button. Mutations invalidate `['show-events']` and `['show-event', id]`. The "Add to Event" flow lives in `components/AddToEventModal.jsx` (launched per-row from `Shows.jsx`): pick an existing event or create one inline, then `addShowToEvent`; shows a success state with a link to the event. **Phase 3** adds a Shows/Entries tab switch to `ShowEventDetail.jsx`: the **Entries tab** groups entries per show, with an "Add Rabbit" picker (`components/RabbitPicker.jsx` — a modal that client-side searches the herd by name/tattoo/breed/variety and disables rabbits already entered in that show), inline-editable `entry_class`/`notes` (saved on blur via `updateShowEntry`), a remove action, and an "in multiple shows" badge for a rabbit entered across more than one show in the event. "Generate Entries Report" opens `components/ReportModal.jsx`, which fetches `getEventReport` and offers Copy to Clipboard. **Phase 4** adds a third **Results** tab plus: a `JudgesSection` on each Shows-tab card (list + inline add/remove of judges), and a per-entry `PlacementEditor` (toggled by an "Add/Edit results" link in the Entries tab) — placement text, a legs +/- counter (0–3), and BOB/BOS/BIS/RIS checkboxes that persist immediately via `setShowEntryPlacement` (the full-object PUT; legs/checkboxes save on change, text on blur). The **Results tab** (`ResultsTab`) is a read-only rollup aggregated client-side from the embedded placements — one card per rabbit, sorted by total legs, with leg/flag badges. |
|
||||
| `src/pages/PedigreePrint.jsx` | Standalone print-optimized 4-generation pedigree slip at `/herd/:id/pedigree`, deliberately routed **outside** `<Layout>` (in `App.jsx`) so the sidebar/bottom-nav don't render or print. Reuses `GET /api/rabbits/:id/ancestors` + `GET /api/settings` (rabbitry name) + `getRabbit`. Modeled on the original Kindlebox PHP slip. Renders a rabbitry header band (rabbitry name, subject farm/name, printed-on date) above a CSS-grid bracket layout (subject column + parents + grandparents + great-grandparents, each generation's row-spans aligning it against the next). The leftmost column stacks a fill-in **Sold To** block, the **subject box** (color, ear number, DOB, weight, sex), and a **certification + Signed** line — mirroring the old slip. Each ancestor box shows farm+name, color, ear, weight, and DOB via a `nodeInfo()` helper that normalizes linked DB nodes, manual-JSON nodes (parsed via `parseManualAncestor`), and missing nodes (→ "Unknown"); `fmtDob` renders MM-DD-YYYY (or "Unknown"), `fmtWeight` omits unset/zero weights. The user produces the PDF via the browser's Print → Save as PDF — **browser print was the chosen method over a bundled PDF library** to keep the self-hosted footprint dependency-free. A component-scoped `<style>` block sets `@page { size: landscape }` (scoped to this mounted page so it doesn't leak to other routes), hides the on-screen Back/Print buttons (`.no-print`) when printing, and forces `print-color-adjust: exact` so the accent header band prints. A "Print Pedigree" button on `RabbitDetail.jsx`'s action bar links here. |
|
||||
| `src/components/RabbitTimeline.jsx` | The rabbit-detail timeline (a `Card` in `RabbitDetail.jsx`'s right column). Merges **derived** events (birth from `dob`; per-litter "Bred with X" at `breed_date` and "Litter … kindled" at `kindle_date`, derived from the `['litters']` query filtered to this rabbit) with **stored** events (`acquired_date`/`death_date` off the rabbit row; logged `show`/`sale`/`other` events from `GET /api/events`), sorts them by ISO date with a per-type order tiebreaker (`EVENT_TYPES[*].order`), and renders a vertical rail. Logged events are the editable ones: an **"Add Event"** button opens an inline `EventForm` with a **type dropdown** (Show/Sale/Other — Breeding is deliberately omitted since it's auto-derived from litters). The form is type-adaptive: Show shows class/placement fields, Sale/Other are just date+title+notes. Mutations go through `createEvent`/`updateEvent`/`deleteEvent` then `invalidateQueries(['events', id])`; a **sale** additionally invalidates `['rabbit']` + `['stats']` because the server flipped the status to Sold. See the rabbit-timeline convention note below. |
|
||||
| `src/components/RabbitCard.jsx` | Used by `Herd.jsx`'s grid view. Photo or inline SVG silhouette placeholder, name, `color_name • Sex`, `age • weight`, and a hardcoded `0 litters • 0 kits` line (no per-rabbit aggregation endpoint exists yet — see Step 6.1 in `progress.md` before wiring this up for real). |
|
||||
| `src/components/RabbitCard.jsx` | Herd grid card: `CoatPanel` (photo or coat-colored lop placeholder on a soft tint), a status badge only when not Active, name + short `SexBadge`, `color · age`, and the ear number (mono chip) + weight. Uses the `.lift` hover class from `index.css`. |
|
||||
| `src/components/CoatArt.jsx` + `src/lib/coat.js` | The photo stand-in. `coatColors(color_name)` reads the free-text ARBA color name loosely (broken/tort/otter/pointed/harlequin/magpie + self colors, `N/A` → neutral) into `{base, patch, ear}` fills for a little lop SVG; `tintFor(id)` picks a stable soft background hue per rabbit. `CoatPanel` (rectangular, cards + detail hero) and `CoatAvatar` (round, lists/pickers/search results) both show the real photo when `photo_path` is set. Purely presentational — never stored. |
|
||||
| `src/components/Icon.jsx` | The shared inline stroke-icon set (nav, buttons, dashboard, milestones) — replaces the icon map that used to live inside `Layout.jsx`. Add new glyphs here rather than inlining SVG in pages. |
|
||||
| `src/components/BrandMark.jsx`, `ThemeToggle.jsx` | The tilted accent app mark (sidebar, mobile header, login/setup screens), and the one-tap light/dark flip (sidebar footer + mobile More menu) — it keeps the palette, pins the mode opposite to what's showing, applies it and PATCHes `settings.theme`. |
|
||||
| `src/lib/format.js` | `formatAge(dob)` (years/months string or "Age unknown"), `formatWeight(weightLbs)` (`"X.XX lbs"` or `null`), `SEX_LABELS`. Shared between `RabbitCard.jsx` and `Herd.jsx`'s list view — add any future age/weight formatting here rather than duplicating. |
|
||||
| `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). |
|
||||
|
|
@ -75,15 +78,15 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
| `src/lib/imageResize.js` | `downscaleImage(file, maxLongEdge=2400)` → JPEG Blob via `createImageBitmap(..., {imageOrientation: "from-image"})` + canvas. |
|
||||
| `src/lib/pedigree.js` (scan helpers) | `pedigreeStateFromScan(result)` → PedigreeTree state (matched sire/dam `linked`, other named ancestors `manual` JSON) and `uncertainScanSlots(result)` → Set of slots to highlight. `AddRabbit` remounts the tree with `key={pedigreeKey}` + `initialState={pedigreeSeed}` on each scan, merges the subject into `details` (non-null values only), and passes `uncertainSlots`; `PedigreeTree` shows a "Check" badge on each until the user edits that slot. |
|
||||
| `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. A stored theme id is `<palette>-<mode>`: palettes `confetti` (default) / `meadow`, modes `system` (default — follows `prefers-color-scheme` live via a `matchMedia` listener) / `light` / `dark`. `PALETTES` (labels, Settings swatches, per-mode browser `theme-color`), `MODES`, `parseTheme`/`composeTheme`/`normalizeTheme` (unknown ids → `confetti-system`; legacy `midnight` → `confetti-dark`, other pre-redesign ids → default). `applyTheme(id)` resolves the mode and sets `document.documentElement.dataset.theme` to one of the four concrete blocks (`confetti-light` etc.), updates the `theme-color` meta, and mirrors the id to `localStorage` (`mykindlebox-theme`). |
|
||||
| `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/lib/keyboardNav.js` | `handleSearchInputKeyDown`/`handleResultKeyDown` (Phase 11 Step 11.4) — arrow-key roving focus for the `<ul><li><button>` search-results dropdown shape shared by `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx`. ArrowDown/ArrowUp move between the input and result buttons, Escape closes the dropdown and refocuses its trigger button. |
|
||||
| `src/index.css` | Tailwind's three `@tailwind` directives, plus (Phase 11) a global `:focus-visible` accent-outline rule (`!important` — see the focus-ring convention note below) and a `prefers-reduced-motion: reduce` media query zeroing all animation/transition durations app-wide. |
|
||||
| `src/index.css` | The four theme blocks (`confetti-light`/`-dark`, `meadow-light`/`-dark`; `:root` doubles as Confetti light) defining the base tokens, the six party hues and the two shadow tokens; base styles (body colors, tabular figures, theme-aware form controls, iOS 16px inputs); small component classes — `.lift` (card hover lift), `.no-scrollbar`, `.confetti-bg`, and the `.ped-br/.ped-nw/.ped-pair` bracket-connector rules used by both pedigree trees; the global `:focus-visible` rule and the `prefers-reduced-motion` override. |
|
||||
| `index.html` | Loads Google Fonts (DM Sans + Inter) via `<link>` tags, applies `font-body` to `<body>` as the default typeface, and (Phase 11) links `manifest.json` plus a `theme-color` meta tag. |
|
||||
| `public/manifest.json` | PWA manifest (Phase 11 Step 11.1) — `name`/`short_name`/`start_url`/`display: standalone`/`background_color`/`theme_color` plus one 192×192 icon (`icon-192.png`, a flat accent-green square with a cream circle, generated by a one-off hand-rolled PNG-encoder script — not a build dependency, not committed as tooling). |
|
||||
| `public/sw.js` | Service worker (Phase 11 Step 11.2) registered from `main.jsx` on `window.load`. Network-first with a cache (`mykindlebox-shell-v2`) fallback for the app shell; explicitly ignores `/api/*` and `/uploads/*` so API calls fail fast offline (surfaced via `Layout.jsx`'s `OfflineBanner`) instead of serving stale cached JSON silently. The cache name was bumped v1→v2 when the vendored Tesseract assets were removed (2026-10-28) so `activate` deletes installed clients' old ~16 MB precache. |
|
||||
| `tailwind.config.js` | Defines the ten design-token colors (`surface`, `border`, `accent`, etc., sourced from `app-design-document.md`) and the two font families (`font-display` → DM Sans, `font-body` → Inter). **Deliberately Tailwind v3, not v4** — v4 replaced JS-based theme config with CSS `@theme` blocks, which doesn't match how the implementation plan expects tokens to be configured. |
|
||||
| `tailwind.config.js` | Color tokens as CSS variables: the original names (`surface`, `surface-raised`, `surface-sunken`, `border`, `text-primary`, `text-secondary`, `accent`, `accent-hover`, `accent-warm`, `on-accent-warm`, `danger`) plus `border-strong`, `text-faint`, `on-accent` (text on accent fills) and the hue family `c1`…`c6` each with `-soft` (tint) and `-ink` (readable text on the tint). Fonts: `font-display` → Bricolage Grotesque, `font-body` → Figtree, `font-mono` → JetBrains Mono. The `borderRadius` scale is rounder than Tailwind's default (`md` 12px, `lg` 16px … `3xl` 28px) so every existing `rounded-*` picked up the softer look without markup changes; `shadow-card`/`shadow-pop` read the per-theme shadow vars. **Deliberately Tailwind v3, not v4.** |
|
||||
| `postcss.config.js` | Standard Tailwind + Autoprefixer PostCSS pipeline (generated by `tailwindcss init -p`). |
|
||||
| `Dockerfile` | Multi-stage production build: a Node 20 Alpine stage runs `npm ci && npm run build`, then an nginx 1.27 Alpine stage serves the built `dist/` with `nginx.conf` (which reverse-proxies `/api` + `/uploads` to the server container and serves the SPA fallback + `sw.js` no-cache header). The Vite dev server is never run in Docker. |
|
||||
|
||||
|
|
@ -112,8 +115,8 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
- **Weight and DOB need real `<label>` elements, not placeholders**: every other manual-entry field (Farm/Name/Ear Number) relies on `placeholder` text since they start empty, but Weight always has a default value (`0`) and `type="date"` inputs don't reliably render placeholders at all — a `placeholder` attribute on either is effectively invisible. Use a `<label>` above the input instead.
|
||||
- **Focus ring is a global CSS rule, not per-component classes** (Phase 11 Step 11.4): `index.css`'s `:focus-visible { outline: 2px solid theme('colors.accent') !important; ... }` is the single source of the accent-colored keyboard focus ring across the whole app. The `!important` is required — many inputs/buttons already carry `focus:outline-none` (paired with their own `focus:ring-2`), which has higher CSS specificity than a bare `:focus-visible` selector and would otherwise win, leaving keyboard focus invisible on exactly those elements. Don't try to remove this rule or "clean it up" into per-component `focus-visible:` utility classes without re-auditing every interactive element for a visible ring first.
|
||||
- **Search-dropdown keyboard nav lives in `lib/keyboardNav.js`, not duplicated per component**: `PedigreeTree.jsx`'s `AncestorSlot` and `RabbitAutocomplete.jsx` both render an identical `<input>` + `<ul><li><button>` results dropdown. Both wire their `onKeyDown` handlers to the same `handleSearchInputKeyDown`/`handleResultKeyDown` exports rather than each implementing their own arrow-key logic. Any third component with this same dropdown shape should reuse these instead of re-deriving roving focus.
|
||||
- **Theming is CSS-variable swaps driven by `data-theme`, not a JS theme object** (post-Phase-11 feature): the ten design tokens in `tailwind.config.js` resolve to CSS variables (`var(--color-surface)` etc.) rather than literal hex. `src/index.css` defines each theme as a `[data-theme='…']` block overriding those ten variables (`:root` = the default Classic palette); setting `document.documentElement.dataset.theme` re-themes every existing utility class with **no markup changes**, which is why adding a theme is purely: a CSS block in `index.css` + an entry in `src/lib/theme.js`'s `THEMES` array (keep the two id lists in sync, including the inline allowlist array in `index.html`). No alpha modifiers (`bg-accent/20`) are used on theme tokens, so the simple `var(--x)` format suffices — if you ever need one, the variables must switch to space-separated RGB channels + the `rgb(var(--x) / <alpha-value>)` format. **Persistence is dual**: the backend `settings.theme` row (added to `routes/settings.ts`'s `ALLOWED_KEYS` and seeded `'classic'` in `db/client.ts`) is the cross-device source of truth; `localStorage` is only a fast-path cache so the inline `<script>` in `index.html` can apply the last-known theme **before first paint** (avoiding a flash of the default palette). `App.jsx`'s `ThemeSync` (a render-nothing child of the providers) reconciles the document with `settings.theme` once that query resolves. `Settings.jsx`'s picker calls `applyTheme` on click for instant live preview, but only **Save** persists it to the backend — an un-saved preview lingers in `localStorage`/the current tab until the next backend reconcile, which is acceptable. The `:focus-visible` rule's `theme('colors.accent')` now compiles to `var(--color-accent)`, so the focus ring themes correctly too.
|
||||
- **Per-widget color (Dashboard stat tiles)** is a second tier of tokens on top of the ten base ones: `index.css`'s `:root` defines `--stat-1`..`--stat-8` (+ matching `--stat-N-hover`) plus `--stat-fg`/`--stat-fg-muted`, all defaulting *through* the base tokens (`var(--color-surface-raised)` / `surface-sunken` / `text-primary` / `text-secondary`) so by default every tile looks like a plain raised Card in every theme. Only the `carnival` theme overrides them, giving each of the eight dashboard tiles its own vivid color + white tile text — that's what "a theme that colors the widgets too" means here. `Dashboard.jsx`'s `STAT_CARDS` carries the binding as **literal** `bg-[var(--stat-N)] hover:bg-[var(--stat-N-hover)]` strings (not interpolated — Tailwind's content scanner only emits classes it can see spelled out in source), and the tile label/value use `text-[var(--stat-fg-muted)]`/`text-[var(--stat-fg)]`. To add another widget-coloring theme, override these stat vars in its `[data-theme]` block; to color a *different* widget, add a new `--…`-token tier the same way rather than hardcoding hex.
|
||||
- **Theming is CSS-variable swaps driven by `data-theme`, not a JS theme object** (Confetti redesign, 2026-11-01, replacing the six single-mode themes): every Tailwind color token resolves to a CSS variable, and `src/index.css` defines one `[data-theme='<palette>-<light|dark>']` block per concrete palette × mode. The *stored* choice can be `…-system`, which `lib/theme.js` resolves at runtime — so the attribute on `<html>` is always one of the four concrete ids, never `-system`. Adding a palette = two CSS blocks (light + dark) + a `PALETTES` entry + the palette id in `index.html`'s inline allowlist. No alpha modifiers (`bg-accent/20`) work on these tokens (plain `var(--x)` hex values); use the `-soft` hue tints instead. **Color roles**: neutrals carry the layout; `accent` is the one primary action color (with `on-accent` for text on it); the six hues are semantic and consistent app-wide — `c1` violet herd/active, `c2` pink does/dam side/due, `c3` orange litters/kits/ear #, `c4` teal for sale/done, `c5` yellow coming up/today/nest box, `c6` blue bucks/sire side/sold. Status badges, sex badges, dashboard tiles, pedigree nodes (sire side blue, dam side pink) and timeline markers all draw from this map — reuse it rather than introducing new hex. **Persistence is dual**: the per-user `settings.theme` (seeded `'confetti-system'` in `db/client.ts` for new installs; old stored ids are normalized client-side) is the cross-device source of truth; `localStorage` is a fast-path cache so the inline `<script>` in `index.html` (which mirrors `parseTheme`, including resolving `system` via `matchMedia`) applies the theme **before first paint**. `App.jsx`'s `ThemeSync` reconciles once settings load. Settings' palette cards + Auto/Light/Dark segmented control preview instantly; only **Save** persists. The sidebar `ThemeToggle` saves immediately.
|
||||
- **Dashboard colors** come straight from the hue tokens (literal `bg-c1-soft text-c1-ink` etc. class strings — Tailwind only emits classes it can see whole). The old `--stat-N` tier and the `carnival` theme are gone.
|
||||
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
|
||||
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
|
||||
- **Auth is account-based and always on** (`FEATURE_User_Accounts.md`, replacing the original optional shared-password mode): accounts live in `users`, sessions in `auth_sessions` (so restarts don't sign anyone out), passwords are scrypt-hashed via `node:crypto` (**deliberately not bcrypt/argon2** — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created through `POST /api/auth/setup`, which only works while the `users` table is empty — the client's `AuthProvider` shows a `SetupScreen` when `GET /api/auth/status` reports `configured: false`. Any signed-in user manages users at `/api/users` (Settings → Users card); you can't delete yourself, which transitively protects the last account. **`/api/settings` kept its exact single-object shape** but storage split: `theme`/`weight_unit` read/write the caller's `users` row (NULL falls back to the global settings value), the rest stay in the `settings` table — client consumers (ThemeSync, `useWeightUnit`, the Settings page) required no changes. The old `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` env var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping.
|
||||
|
|
@ -140,4 +143,10 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|
|||
- Dashboard: Herd / Bucks / Does / Kits are the large tiles; For Sale / On Hold / Sold / Archived are a compact second row (same `--stat-N` theme vars).
|
||||
- Shows: stacked `ShowListCard`s below `xl`; the 9-column table only at `xl`+ where it fits beside the sidebar.
|
||||
- Rabbit timeline markers are inline SVG stroke icons (no emoji anywhere in the client).
|
||||
- **Confetti redesign (2026-11-01)** — visual + UX pass chosen from four design-canvas directions (Heritage, Modern, Lantern, Confetti):
|
||||
- **Dashboard** (`pages/Dashboard.jsx`): an accent "party" hero whose headline is *today* (a birthday, else today's task, else a greeting) with a one-line herd summary and Add rabbit / Record breeding; four colored headline tiles (active herd, for sale, bucks, does) + a compact row (kits, on hold, sold, archived); **Coming up** (next 45 days), **Expecting** (one gestation bar per pending breeding with palpate/nest-box ticks), **Recent litters** (kits drawn as colored dots, `KitDots` from `LitterCard.jsx`), and a horizontal **For sale** strip. The schedule is **derived, never stored**: `useSchedule()` turns each un-kindled litter's `breed_date` into palpate (+`PALPATE_DAYS` 12), nest box (+`NEST_BOX_DAYS` 28) and due (+`GESTATION_DAYS` 31) events, each kindled litter into a wean event (+`WEAN_DAYS` 56), and active rabbits' DOBs into birthdays (the old 30-day `getUpcomingBirthdays`, now feeding the same list). The milestone constants and `daysUntil`/`displayColor` live in `lib/format.js`.
|
||||
- **Rabbit detail**: back link + Edit/More header; a hero card (`CoatPanel`, status/sex/breed chips, big name, `color · from farm`, tinted fact tiles, genetics chips, the status select); a full-width **3-generation bracket pedigree** (`PedigreeTreeReadOnly`, connectors from the `.ped-*` CSS, sire side blue / dam side pink, stacked layout below ~860px); litters as rows with kit dots; timeline markers tinted per event type.
|
||||
- **Add rabbit**: pill stepper; a **Quick pick** card (`SuggestedParents` in `AddRabbit.jsx`) listing dams and sires as one-tap chips — parents of the most recent litters first ("Bred recently"), topped up with active does/bucks. A chip calls `PedigreeTree`'s imperative `setParent(slot, rabbit)` (React 19 `ref` prop + `useImperativeHandle`), which links the slot exactly like picking from search and fetches the cascade, so the rest of that side fills in. The editable tree uses the same bracket layout and side colors; unfilled slots are dashed "Unknown — choose"; the picker offers "From your herd" (results with `CoatAvatar`) / "Type it in".
|
||||
- **Litter detail**: parent tiles, kit count, and a **milestone strip** (Bred → Palpate → Nest box → Due, or Bred → Kindled → Wean) marked done/today/upcoming; the kits table only shows once kindled.
|
||||
- Herd: pill sex filters, pill search, segmented card/list toggle; list rows carry avatars and sex badges. Litters: two-column card grid. Auth screens: rounded card with brand mark over a confetti scatter.
|
||||
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.
|
||||
|
|
|
|||
|
|
@ -4,27 +4,35 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#4A6741" />
|
||||
<meta name="theme-color" content="#faf8ff" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<!-- Apply the cached theme before first paint to avoid a flash of the
|
||||
default palette. The backend `settings.theme` is the source of truth
|
||||
(synced across devices) and reconciles this once it loads; this just
|
||||
restores the last-known choice instantly. Keep ids in sync with
|
||||
src/lib/theme.js. -->
|
||||
restores the last-known choice instantly. Mirrors parseTheme() in
|
||||
src/lib/theme.js — keep the palette/mode ids in sync. -->
|
||||
<script>
|
||||
(function () {
|
||||
var palette = 'confetti', mode = 'system'
|
||||
try {
|
||||
var t = localStorage.getItem('mykindlebox-theme')
|
||||
if (t && ['classic', 'midnight', 'ocean', 'berry', 'sunset', 'carnival'].indexOf(t) !== -1) {
|
||||
document.documentElement.dataset.theme = t
|
||||
if (t === 'midnight') t = 'confetti-dark'
|
||||
var parts = (t || '').split('-')
|
||||
if (['confetti', 'meadow'].indexOf(parts[0]) !== -1 && ['system', 'light', 'dark'].indexOf(parts[1]) !== -1) {
|
||||
palette = parts[0]
|
||||
mode = parts[1]
|
||||
}
|
||||
} catch (e) {}
|
||||
if (mode === 'system') {
|
||||
mode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.dataset.theme = palette + '-' + mode
|
||||
})()
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@400;500;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=Figtree:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>MyKindlebox</title>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"react": "^19.2.6",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 423 B |
|
|
@ -3,8 +3,8 @@
|
|||
"short_name": "Kindlebox",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F7F3EC",
|
||||
"theme_color": "#4A6741",
|
||||
"background_color": "#FAF8FF",
|
||||
"theme_color": "#6A48EE",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
|
|
|
|||
|
|
@ -10,22 +10,34 @@ import {
|
|||
import { AuthContext } from './authContext'
|
||||
import Button from './components/Button'
|
||||
import { Field, Input } from './components/FormControls'
|
||||
import BrandMark from './components/BrandMark'
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
|
||||
|
||||
function AuthShell({ title, subtitle, onSubmit, error, children }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-surface px-4">
|
||||
<svg className="pointer-events-none absolute inset-0 h-full w-full" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<rect x="120" y="90" width="22" height="10" rx="5" fill="var(--c2)" transform="rotate(25 131 95)" />
|
||||
<circle cx="640" cy="120" r="9" fill="var(--c5)" />
|
||||
<rect x="660" y="430" width="22" height="10" rx="5" fill="var(--c4)" transform="rotate(-30 671 435)" />
|
||||
<circle cx="160" cy="470" r="7" fill="var(--c6)" />
|
||||
<rect x="560" y="520" width="18" height="8" rx="4" fill="var(--c3)" transform="rotate(40 569 524)" />
|
||||
<circle cx="250" cy="200" r="5" fill="var(--c1)" />
|
||||
</svg>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm space-y-4 rounded-md border border-border bg-surface-raised p-6"
|
||||
className="relative w-full max-w-sm space-y-5 rounded-[28px] border border-border bg-surface-raised p-7 shadow-pop"
|
||||
>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl text-text-primary">{title}</h1>
|
||||
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<BrandMark className="h-12 w-12" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold tracking-tight text-text-primary">{title}</h1>
|
||||
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
{error && <p className="font-body text-sm font-semibold text-danger">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
|
|||
import Button from './Button'
|
||||
|
||||
const ITEM_CLASS =
|
||||
'flex min-h-[44px] w-full items-center rounded px-3 text-left font-body text-sm text-text-primary hover:bg-surface-sunken md:min-h-[36px]'
|
||||
'flex min-h-[44px] w-full items-center rounded-xl px-3 text-left font-body text-sm font-semibold text-text-primary hover:bg-surface-sunken md:min-h-[40px]'
|
||||
|
||||
// A "More" button that opens a short list of secondary actions, so a page
|
||||
// header can keep its primary actions visible without wrapping into several
|
||||
|
|
@ -46,7 +46,7 @@ function ActionMenu({ label = 'More', items, size = 'sm' }) {
|
|||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-1 w-52 rounded-lg border border-border bg-surface-raised p-1 shadow-lg"
|
||||
className="absolute right-0 z-30 mt-2 w-56 rounded-2xl border border-border bg-surface-raised p-1.5 shadow-pop"
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.to ? (
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function AddToEventModal({ show, onClose }) {
|
|||
<div className="w-full max-w-md rounded-lg border border-border bg-surface-raised p-6 shadow-xl">
|
||||
{result ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Added to event</h2>
|
||||
<h2 className="font-display text-lg font-bold text-text-primary">Added to event</h2>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
<span className="font-medium text-text-primary">{show.club_name}</span> was added to{' '}
|
||||
<span className="font-medium text-text-primary">{result.name}</span>.
|
||||
|
|
@ -73,7 +73,7 @@ function AddToEventModal({ show, onClose }) {
|
|||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Add to Event</h2>
|
||||
<h2 className="font-display text-lg font-bold text-text-primary">Add to Event</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
{show.club_name} — {show.raw_date || show.date}
|
||||
</p>
|
||||
|
|
@ -85,7 +85,7 @@ function AddToEventModal({ show, onClose }) {
|
|||
type="button"
|
||||
onClick={() => setMode('existing')}
|
||||
className={`flex-1 rounded px-3 py-1.5 font-body text-sm ${
|
||||
effectiveMode === 'existing' ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
effectiveMode === 'existing' ? 'bg-accent text-on-accent' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
Existing event
|
||||
|
|
@ -94,7 +94,7 @@ function AddToEventModal({ show, onClose }) {
|
|||
type="button"
|
||||
onClick={() => setMode('new')}
|
||||
className={`flex-1 rounded px-3 py-1.5 font-body text-sm ${
|
||||
effectiveMode === 'new' ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
effectiveMode === 'new' ? 'bg-accent text-on-accent' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
New event
|
||||
|
|
|
|||
|
|
@ -1,22 +1,44 @@
|
|||
import { STATUS_LABELS } from '../lib/format'
|
||||
|
||||
// Soft tinted chips — color carries the status, never the only cue (the label
|
||||
// is always there).
|
||||
const STATUS_CLASSES = {
|
||||
active: 'bg-accent text-surface',
|
||||
for_sale: 'bg-accent-warm text-on-accent-warm',
|
||||
on_hold: 'bg-text-secondary text-surface',
|
||||
sold: 'bg-border text-text-primary',
|
||||
deceased: 'bg-danger text-surface',
|
||||
other: 'bg-border text-text-primary',
|
||||
active: 'bg-c1-soft text-c1-ink',
|
||||
for_sale: 'bg-c4-soft text-c4-ink',
|
||||
on_hold: 'bg-c5-soft text-c5-ink',
|
||||
sold: 'bg-c6-soft text-c6-ink',
|
||||
deceased: 'bg-surface-sunken text-text-secondary',
|
||||
other: 'bg-surface-sunken text-text-secondary',
|
||||
}
|
||||
|
||||
function Badge({ status }) {
|
||||
function Badge({ status, className = '' }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2.5 py-0.5 font-body text-xs font-medium ${STATUS_CLASSES[status] ?? STATUS_CLASSES.other}`}
|
||||
className={`inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 font-body text-xs font-bold ${STATUS_CLASSES[status] ?? STATUS_CLASSES.other} ${className}`}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Buck / Doe marker — blue and pink, matching the dashboard tiles.
|
||||
const SEX_CLASSES = {
|
||||
buck: 'bg-c6-soft text-c6-ink',
|
||||
doe: 'bg-c2-soft text-c2-ink',
|
||||
}
|
||||
const SEX_SHORT = { buck: 'Buck', doe: 'Doe' }
|
||||
|
||||
export function SexBadge({ sex, short = false, className = '' }) {
|
||||
if (!SEX_CLASSES[sex]) return null
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-6 shrink-0 items-center rounded-full px-2.5 font-body text-xs font-extrabold ${SEX_CLASSES[sex]} ${className}`}
|
||||
title={SEX_SHORT[sex]}
|
||||
>
|
||||
{short ? SEX_SHORT[sex][0] : SEX_SHORT[sex]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import Icon from './Icon'
|
||||
|
||||
// The app mark: a tilted accent tile with the bunny glyph.
|
||||
function BrandMark({ className = 'h-11 w-11' }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex shrink-0 -rotate-6 items-center justify-center rounded-[14px] bg-accent text-on-accent shadow-[0_8px_18px_-8px_var(--color-accent)] ${className}`}
|
||||
>
|
||||
<Icon name="bunny" className="h-[58%] w-[58%]" strokeWidth={2} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default BrandMark
|
||||
|
|
@ -62,7 +62,7 @@ function BreedSelect({ value = '', onChange, placeholder = 'e.g. Holland Lop', i
|
|||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-full rounded-md border border-border bg-surface-raised p-1 shadow-sm">
|
||||
<div className="absolute left-0 top-full z-30 mt-2 w-full rounded-2xl border border-border bg-surface-raised p-1.5 shadow-pop">
|
||||
<ul className="max-h-56 overflow-auto">
|
||||
{matches.map((breed, index) => (
|
||||
<li key={breed}>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,27 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
// Pill buttons. Primary carries a soft glow in its own color so the one main
|
||||
// action on a screen reads first.
|
||||
const VARIANT_CLASSES = {
|
||||
primary: 'bg-accent text-surface hover:bg-accent-hover',
|
||||
secondary: 'border border-border bg-transparent text-text-primary hover:bg-surface-sunken',
|
||||
danger: 'bg-danger text-surface hover:opacity-90',
|
||||
primary:
|
||||
'border border-accent bg-accent text-on-accent shadow-[0_8px_20px_-10px_var(--color-accent)] hover:border-accent-hover hover:bg-accent-hover',
|
||||
secondary: 'border-[1.5px] border-border bg-surface-raised text-text-primary hover:bg-surface-sunken',
|
||||
ghost: 'border border-transparent bg-transparent text-text-primary hover:bg-surface-sunken',
|
||||
danger: 'border border-danger bg-danger text-on-accent hover:opacity-90',
|
||||
}
|
||||
|
||||
// Heights hit ~40px (sm) / 44px (md) on touch screens; sm relaxes to a denser
|
||||
// 32px on desktop where pointer precision allows it.
|
||||
// Heights hit ~40px (sm) / 46px (md) on touch screens; sm relaxes to a denser
|
||||
// 36px on desktop where pointer precision allows it.
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'min-h-[40px] px-3 py-1.5 text-sm md:min-h-[32px]',
|
||||
md: 'min-h-[44px] px-4 py-2 text-base md:min-h-[40px]',
|
||||
sm: 'min-h-[40px] gap-1.5 px-4 py-1.5 text-sm md:min-h-[36px]',
|
||||
md: 'min-h-[46px] gap-2 px-5 py-2 text-[15px] md:min-h-[44px]',
|
||||
}
|
||||
|
||||
// With `to`, renders a router <Link> styled as a button — use this for
|
||||
// navigation instead of wrapping a <Button> in a <Link> (a button nested in a
|
||||
// link is invalid HTML and adds a second tab stop).
|
||||
function Button({ variant = 'primary', size = 'md', className = '', to, ...props }) {
|
||||
const classes = `inline-flex items-center justify-center rounded-md font-body font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`
|
||||
const classes = `inline-flex items-center justify-center whitespace-nowrap rounded-full font-body font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`
|
||||
if (to) return <Link to={to} className={classes} {...props} />
|
||||
return <button className={classes} {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
function Card({ className = '', ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border bg-surface-raised shadow-sm ${className}`}
|
||||
className={`rounded-3xl border border-border bg-surface-raised shadow-card ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { SERVER_BASE } from '../api'
|
||||
import { coatColors, tintFor } from '../lib/coat'
|
||||
|
||||
// A little lop drawn in the rabbit's coat colors — the stand-in for a photo.
|
||||
function LopSvg({ colorName, className = '' }) {
|
||||
const { base, patch, ear } = coatColors(colorName)
|
||||
return (
|
||||
<svg viewBox="0 0 120 96" className={className} aria-hidden="true">
|
||||
<ellipse cx="70" cy="64" rx="34" ry="24" fill={base} />
|
||||
<circle cx="84" cy="60" r="20" fill={base} />
|
||||
<ellipse cx="74" cy="58" rx="14" ry="10" fill={patch} />
|
||||
<circle cx="40" cy="48" r="20" fill={base} />
|
||||
<circle cx="34" cy="47" r="7" fill={patch} />
|
||||
<ellipse cx="50" cy="50" rx="8" ry="18" transform="rotate(14 50 50)" fill={ear} />
|
||||
<circle cx="31" cy="44" r="2" fill="#1c1530" opacity="0.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// The rectangular art panel on rabbit cards and the detail hero: the photo
|
||||
// when there is one, otherwise the coat-colored lop on a soft tint.
|
||||
export function CoatPanel({ rabbit, className = '' }) {
|
||||
if (rabbit.photo_path) {
|
||||
return (
|
||||
<div className={`overflow-hidden bg-surface-sunken ${className}`}>
|
||||
<img src={`${SERVER_BASE}${rabbit.photo_path}`} alt={rabbit.name} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${tintFor(rabbit.id)} ${className}`}>
|
||||
<LopSvg colorName={rabbit.color_name} className="w-[74%]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Round avatar for list rows, pickers and pedigree chips.
|
||||
export function CoatAvatar({ rabbit, className = 'h-10 w-10' }) {
|
||||
if (rabbit?.photo_path) {
|
||||
return (
|
||||
<img
|
||||
src={`${SERVER_BASE}${rabbit.photo_path}`}
|
||||
alt=""
|
||||
className={`shrink-0 rounded-full object-cover ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`flex shrink-0 items-center justify-center overflow-hidden rounded-full ${tintFor(rabbit?.id)} ${className}`}>
|
||||
<LopSvg colorName={rabbit?.color_name} className="mt-[8%] w-[118%] max-w-none" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoatPanel
|
||||
|
|
@ -33,10 +33,10 @@ function ConfirmDialog({
|
|||
e.preventDefault()
|
||||
if (!busy) onCancel()
|
||||
}}
|
||||
className="w-[calc(100%-2rem)] max-w-md rounded-lg border border-border bg-surface-raised p-0 text-text-primary shadow-lg backdrop:bg-black/40"
|
||||
className="w-[calc(100%-2rem)] max-w-md rounded-3xl border border-border bg-surface-raised p-0 text-text-primary shadow-pop backdrop:bg-black/50"
|
||||
>
|
||||
<div className="space-y-4 p-6">
|
||||
<h2 id={titleId} className="font-display text-lg text-text-primary">
|
||||
<h2 id={titleId} className="font-display text-lg font-bold text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
{children && <div className="space-y-2 font-body text-sm text-text-secondary">{children}</div>}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
// A friendly empty/failed state: a little confetti burst over the message.
|
||||
function EmptyState({ title, description, action }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
|
||||
<h3 className="font-display text-lg text-text-primary">{title}</h3>
|
||||
{description && <p className="font-body text-sm text-text-secondary">{description}</p>}
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-14 text-center">
|
||||
<svg viewBox="0 0 96 48" className="mb-2 h-12 w-24" aria-hidden="true">
|
||||
<rect x="10" y="20" width="12" height="6" rx="3" fill="var(--c2)" transform="rotate(-24 16 23)" />
|
||||
<circle cx="34" cy="10" r="4" fill="var(--c5)" />
|
||||
<rect x="42" y="26" width="12" height="6" rx="3" fill="var(--c1)" transform="rotate(18 48 29)" />
|
||||
<circle cx="62" cy="14" r="5" fill="var(--c4)" />
|
||||
<rect x="72" y="28" width="12" height="6" rx="3" fill="var(--c3)" transform="rotate(-30 78 31)" />
|
||||
<circle cx="86" cy="8" r="3" fill="var(--c6)" />
|
||||
</svg>
|
||||
<h3 className="font-display text-xl font-bold text-text-primary">{title}</h3>
|
||||
{description && <p className="max-w-sm font-body text-sm text-text-secondary">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ const FieldContext = createContext(null)
|
|||
// md: the standard form size (44px touch / 40px desktop, like <Button>).
|
||||
// sm: filter bars and other dense rows (44px touch / 32px desktop).
|
||||
const SIZE_CLASSES = {
|
||||
md: 'min-h-[44px] px-3 py-2 md:min-h-[40px]',
|
||||
sm: 'min-h-[44px] px-3 py-1.5 md:min-h-[32px]',
|
||||
multiline: 'px-3 py-2',
|
||||
md: 'min-h-[46px] rounded-xl px-3.5 py-2 md:min-h-[44px]',
|
||||
sm: 'min-h-[44px] rounded-full px-4 py-1.5 md:min-h-[38px]',
|
||||
multiline: 'rounded-xl px-3.5 py-2.5',
|
||||
}
|
||||
|
||||
function controlClasses({ size = 'md', invalid, className = '' }) {
|
||||
return `rounded-md border bg-surface-raised font-body text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
return `border-[1.5px] bg-surface-raised font-body text-sm text-text-primary transition-colors hover:border-border-strong focus:border-accent focus:outline-none focus:ring-4 focus:ring-c1-soft disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
invalid ? 'border-danger' : 'border-border'
|
||||
} ${SIZE_CLASSES[size]} ${className}`
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ export function FileInput(props) {
|
|||
{...props}
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="block w-full font-body text-sm text-text-secondary file:mr-3 file:min-h-[40px] file:cursor-pointer file:rounded-md file:border file:border-solid file:border-border file:bg-surface-raised file:px-3 file:py-2 file:font-body file:text-sm file:font-medium file:text-text-primary hover:file:bg-surface-sunken"
|
||||
className="block w-full font-body text-sm text-text-secondary file:mr-3 file:min-h-[40px] file:cursor-pointer file:rounded-full file:border-[1.5px] file:border-solid file:border-border file:bg-surface-raised file:px-4 file:py-2 file:font-body file:text-sm file:font-bold file:text-text-primary hover:file:bg-surface-sunken"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -75,17 +75,17 @@ export function FileInput(props) {
|
|||
// dropdown for two to four short choices. `options` is [{ value, label }].
|
||||
export function Segmented({ options, value, onChange, label }) {
|
||||
return (
|
||||
<div className="flex gap-1" role="group" aria-label={label}>
|
||||
<div className="flex gap-1 rounded-full border-[1.5px] border-border bg-surface-sunken p-1" role="group" aria-label={label}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
aria-pressed={value === opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`min-h-[44px] flex-1 rounded-md border px-3 py-2 font-body text-sm md:min-h-[40px] ${
|
||||
className={`min-h-[40px] flex-1 rounded-full px-3 py-1.5 font-body text-sm font-bold transition-colors md:min-h-[36px] ${
|
||||
value === opt.value
|
||||
? 'border-accent bg-accent text-surface'
|
||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||
? 'bg-surface-raised text-text-primary shadow-card'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
|
@ -100,7 +100,7 @@ export function Segmented({ options, value, onChange, label }) {
|
|||
export function FieldGroup({ label, hint, className = '', children }) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className="block font-body text-sm font-medium text-text-primary">{label}</span>
|
||||
<span className="block font-body text-sm font-bold text-text-primary">{label}</span>
|
||||
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
|
||||
<div className={hint ? 'mt-2' : 'mt-1'}>{children}</div>
|
||||
</div>
|
||||
|
|
@ -115,11 +115,11 @@ export function Field({ label, hint, error, required, className = '', children }
|
|||
const message = error || hint
|
||||
return (
|
||||
<div className={className}>
|
||||
<label htmlFor={id} className="block font-body text-sm font-medium text-text-primary">
|
||||
<label htmlFor={id} className="block font-body text-sm font-bold text-text-primary">
|
||||
{label}
|
||||
{required && <span className="text-danger"> *</span>}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<div className="mt-1.5">
|
||||
<FieldContext.Provider
|
||||
value={{ id, describedBy: message ? messageId : undefined, invalid: Boolean(error) }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function GeneticsForm({ value, onChange, defaultExpanded = false, onUseColor })
|
|||
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 ${
|
||||
value[locus.key] === opt.value
|
||||
? 'border-accent bg-accent text-surface'
|
||||
? 'border-accent bg-accent text-on-accent'
|
||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
// Stroke icons shared across the app (sidebar, bottom nav, dashboard tiles,
|
||||
// buttons). Kept inline — no icon library — to match the other hand-rolled
|
||||
// SVGs. All are drawn on a 24px grid with round caps/joins so they read as
|
||||
// one family at any stroke width.
|
||||
const PATHS = {
|
||||
dashboard: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="9" rx="2" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="2" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="2" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="2" />
|
||||
</>
|
||||
),
|
||||
herd: (
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5" />
|
||||
<path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.7.8 2.7 2.5 3 5.2" />
|
||||
</>
|
||||
),
|
||||
litters: (
|
||||
<>
|
||||
<path d="M4 10h16v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-8Z" />
|
||||
<path d="M2.5 10h19M8 6.5c0-1.4 1.8-2.5 4-2.5s4 1.1 4 2.5" />
|
||||
</>
|
||||
),
|
||||
genetics: <path d="M7 3c0 6 10 6 10 12 0 3-2 5-2 6M17 3c0 6-10 6-10 12 0 3 2 5 2 6M8.5 7h7M8.5 17h7" />,
|
||||
shows: (
|
||||
<>
|
||||
<circle cx="12" cy="9" r="5.5" />
|
||||
<path d="m9 14-1.5 7 4.5-2.5 4.5 2.5L15 14" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</>
|
||||
),
|
||||
add: <path d="M12 5v14M5 12h14" />,
|
||||
more: (
|
||||
<>
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</>
|
||||
),
|
||||
bunny: (
|
||||
<>
|
||||
<circle cx="12" cy="14" r="6" />
|
||||
<path d="M9 9.2C7.6 7 7.4 3.6 8.8 3.1c1.3-.4 2.4 2.6 2.6 5.3M15 9.2c1.4-2.2 1.6-5.6.2-6.1-1.3-.4-2.4 2.6-2.6 5.3" />
|
||||
<path d="M10.5 15.5c.8.6 2.2.6 3 0" />
|
||||
</>
|
||||
),
|
||||
heart: <path d="M12 20s-7.5-4.6-7.5-10A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 7.5 3c0 5.4-7.5 10-7.5 10Z" />,
|
||||
cake: (
|
||||
<>
|
||||
<path d="M4 21h16M5 21v-7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v7" />
|
||||
<path d="M5 16c1.5 1 3 1 4.5 0s3-1 4.5 0 3 1 4.5 0M12 12V9M12 6.5c.8-.8.8-1.7 0-2.5-.8.8-.8 1.7 0 2.5Z" />
|
||||
</>
|
||||
),
|
||||
tag: (
|
||||
<>
|
||||
<path d="M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9-9-9Z" />
|
||||
<circle cx="7.5" cy="7.5" r="1.5" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="16" rx="3" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</>
|
||||
),
|
||||
nest: (
|
||||
<>
|
||||
<path d="M3 11h18l-1.5 7.5a2 2 0 0 1-2 1.5h-11a2 2 0 0 1-2-1.5L3 11Z" />
|
||||
<path d="M7 11c0-2.5 2.2-4.5 5-4.5s5 2 5 4.5" />
|
||||
</>
|
||||
),
|
||||
hand: (
|
||||
<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11m0-1.5V4a1.5 1.5 0 0 1 3 0v6m0-4.5a1.5 1.5 0 0 1 3 0V12m0-3.5a1.5 1.5 0 0 1 3 0V15a6 6 0 0 1-6 6h-1.6a6 6 0 0 1-4.6-2.2L4.5 15.6a1.6 1.6 0 0 1 2.4-2.1L8 14.5" />
|
||||
),
|
||||
scale: (
|
||||
<>
|
||||
<path d="M5 21h14a1 1 0 0 0 1-1.2l-1.6-8A1 1 0 0 0 17.4 11H6.6a1 1 0 0 0-1 .8l-1.6 8A1 1 0 0 0 5 21Z" />
|
||||
<circle cx="12" cy="6" r="3" />
|
||||
</>
|
||||
),
|
||||
chevronRight: <path d="m9 6 6 6-6 6" />,
|
||||
chevronLeft: <path d="m15 6-6 6 6 6" />,
|
||||
chevronDown: <path d="m6 9 6 6 6-6" />,
|
||||
check: <path d="m5 12.5 4.5 4.5L19 7" />,
|
||||
x: <path d="M6 6l12 12M18 6 6 18" />,
|
||||
edit: (
|
||||
<>
|
||||
<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z" />
|
||||
<path d="m13.5 6.5 4 4" />
|
||||
</>
|
||||
),
|
||||
print: (
|
||||
<>
|
||||
<path d="M7 9V3h10v6M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="7" y="14" width="10" height="7" rx="1" />
|
||||
</>
|
||||
),
|
||||
camera: (
|
||||
<>
|
||||
<path d="M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z" />
|
||||
<circle cx="12" cy="13.5" r="3.5" />
|
||||
</>
|
||||
),
|
||||
sparkle: <path d="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7ZM19 16c.3 1.6 1.1 2.4 2.5 2.7-1.4.3-2.2 1.1-2.5 2.6-.3-1.5-1.1-2.3-2.5-2.6 1.4-.3 2.2-1.1 2.5-2.7Z" />,
|
||||
grid: (
|
||||
<>
|
||||
<rect x="3.5" y="3.5" width="7" height="7" rx="2" />
|
||||
<rect x="13.5" y="3.5" width="7" height="7" rx="2" />
|
||||
<rect x="3.5" y="13.5" width="7" height="7" rx="2" />
|
||||
<rect x="13.5" y="13.5" width="7" height="7" rx="2" />
|
||||
</>
|
||||
),
|
||||
list: <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" />,
|
||||
filter: <path d="M4 6h16M7 12h10M10 18h4" />,
|
||||
sun: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2.5v2M12 19.5v2M4.6 4.6 6 6M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" />
|
||||
</>
|
||||
),
|
||||
moon: <path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" />,
|
||||
logout: (
|
||||
<>
|
||||
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3" />
|
||||
<path d="M10 17l-5-5 5-5M5 12h11" />
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
function Icon({ name, className = 'h-5 w-5', strokeWidth = 1.9 }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
||||
|
|
@ -1,99 +1,131 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getSettings, getStats } from '../api'
|
||||
import { useAuth } from '../authContext'
|
||||
import BrandMark from './BrandMark'
|
||||
import Icon from './Icon'
|
||||
import QuickSearch from './QuickSearch'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
// Vite inlines just this field; it's bumped by the pre-commit hook
|
||||
// (.githooks/bump-version.mjs) on every commit that touches the app.
|
||||
import { version as APP_VERSION } from '../../package.json'
|
||||
|
||||
// Stroke icons shared by the sidebar and the mobile bottom nav. Kept inline
|
||||
// (no icon library) to match the other hand-rolled SVGs in the app.
|
||||
function Icon({ name, className = 'h-5 w-5' }) {
|
||||
const paths = {
|
||||
dashboard: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1.5" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1.5" />
|
||||
</>
|
||||
),
|
||||
herd: (
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5" />
|
||||
<path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.7.8 2.7 2.5 3 5.2" />
|
||||
</>
|
||||
),
|
||||
litters: (
|
||||
<>
|
||||
<path d="M4 10h16v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-8Z" />
|
||||
<path d="M2.5 10h19M8 6.5c0-1.4 1.8-2.5 4-2.5s4 1.1 4 2.5" />
|
||||
</>
|
||||
),
|
||||
genetics: (
|
||||
<path d="M7 3c0 6 10 6 10 12 0 3-2 5-2 6M17 3c0 6-10 6-10 12 0 3 2 5 2 6M8.5 7h7M8.5 17h7" />
|
||||
),
|
||||
shows: (
|
||||
<>
|
||||
<circle cx="12" cy="9" r="5.5" />
|
||||
<path d="m9 14-1.5 7 4.5-2.5 4.5 2.5L15 14" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</>
|
||||
),
|
||||
add: <path d="M12 5v14M5 12h14" />,
|
||||
more: (
|
||||
<>
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
</>
|
||||
),
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// `tile` is the colored icon tile beside each label — each section keeps its
|
||||
// own hue everywhere (Herd violet, Litters orange, Shows teal…). Literal class
|
||||
// strings because Tailwind only generates classes it can see whole.
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard', end: true },
|
||||
{ to: '/herd', label: 'Herd', icon: 'herd' },
|
||||
{ to: '/litters', label: 'Litters', icon: 'litters' },
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard', end: true, tile: 'bg-c5-soft text-c5-ink' },
|
||||
{ to: '/herd', label: 'Herd', icon: 'herd', tile: 'bg-c1-soft text-c1-ink', count: 'herd' },
|
||||
{ to: '/litters', label: 'Litters', icon: 'litters', tile: 'bg-c3-soft text-c3-ink' },
|
||||
// Genetics is hidden until the pairing calculator is built (the /genetics
|
||||
// route and its icon remain); add it back here and to MORE_ITEMS then.
|
||||
{ to: '/shows', label: 'Shows', icon: 'shows' },
|
||||
{ to: '/settings', label: 'Settings', icon: 'settings' },
|
||||
{ to: '/shows', label: 'Shows', icon: 'shows', tile: 'bg-c4-soft text-c4-ink' },
|
||||
{ to: '/settings', label: 'Settings', icon: 'settings', tile: 'bg-c6-soft text-c6-ink' },
|
||||
]
|
||||
|
||||
// Mobile has room for five tabs. Dashboard/Herd/Add/Litters are the barn-side
|
||||
// essentials; everything else lives behind "More" so no page is unreachable on
|
||||
// a phone (Shows and Genetics previously had no mobile entry point at all).
|
||||
// a phone.
|
||||
const MORE_ITEMS = NAV_ITEMS.filter((item) => ['/shows', '/settings'].includes(item.to))
|
||||
|
||||
const navLinkClass = ({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-md px-3 py-2 font-body text-sm ${
|
||||
isActive ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`
|
||||
function Avatar({ name, className = 'h-9 w-9' }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex shrink-0 items-center justify-center rounded-xl bg-c3 font-display text-sm font-extrabold uppercase text-[#1c1530] ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{(name ?? '?').slice(0, 1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const bottomNavLinkClass = ({ isActive }) =>
|
||||
`flex min-h-[56px] flex-1 flex-col items-center justify-center gap-1 font-body text-[11px] font-medium ${
|
||||
isActive ? 'text-accent' : 'text-text-secondary'
|
||||
}`
|
||||
function SideNavLink({ item, count, active }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex min-h-[48px] items-center gap-3 rounded-2xl px-2.5 font-body text-[15px] font-semibold text-text-primary transition-colors ${
|
||||
(active ?? isActive) ? 'bg-surface-sunken' : 'hover:bg-surface-sunken'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-[10px] ${item.tile}`}>
|
||||
<Icon name={item.icon} className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
{item.label}
|
||||
{count != null && (
|
||||
<span className="ml-auto rounded-full border border-border px-2 py-px font-body text-xs font-bold text-text-secondary">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop navigation: a floating rounded panel rather than a full-height rail,
|
||||
// so the page background reads around it.
|
||||
function Sidebar({ herdActive }) {
|
||||
const { user, mode, logout } = useAuth()
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
const { data: stats } = useQuery({ queryKey: ['stats'], queryFn: getStats })
|
||||
|
||||
return (
|
||||
<aside className="sticky top-4 m-4 mr-0 hidden h-[calc(100vh-2rem)] w-[248px] shrink-0 flex-col gap-5 overflow-y-auto rounded-[28px] border border-border bg-surface-raised px-3.5 py-5 shadow-card md:flex">
|
||||
<Link to="/" className="flex items-center gap-3 px-1.5">
|
||||
<BrandMark />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-display text-[17px] font-extrabold leading-tight text-text-primary">
|
||||
{settings?.rabbitry_name || 'MyKindlebox'}
|
||||
</span>
|
||||
<span className="block font-body text-xs font-semibold text-text-secondary">MyKindlebox</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/herd/add"
|
||||
className="flex min-h-[46px] items-center justify-center gap-2 rounded-full bg-accent font-body text-[15px] font-bold text-on-accent shadow-[0_8px_20px_-10px_var(--color-accent)] transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
<Icon name="add" className="h-[18px] w-[18px]" strokeWidth={2.4} />
|
||||
Add rabbit
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-col gap-1" aria-label="Primary">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<SideNavLink
|
||||
key={item.to}
|
||||
item={item}
|
||||
count={item.count ? stats?.[item.count] : null}
|
||||
active={item.to === '/herd' ? herdActive : undefined}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex items-center gap-1.5 rounded-[18px] bg-surface-sunken p-2.5">
|
||||
<Avatar name={user?.username} />
|
||||
<span className="ml-1 min-w-0 flex-1">
|
||||
<span className="block truncate font-body text-sm font-bold text-text-primary">
|
||||
{user?.username ?? 'Guest'}
|
||||
</span>
|
||||
<span className="block font-body text-xs text-text-secondary">v{APP_VERSION}</span>
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
{mode === 'authed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl text-text-secondary transition-colors hover:bg-surface-raised hover:text-text-primary"
|
||||
>
|
||||
<Icon name="logout" className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// App-wide rather than per-page: every page fetches via the same `api.js`
|
||||
// helpers, and a failed fetch while offline already degrades gracefully
|
||||
|
|
@ -123,12 +155,17 @@ function OfflineBanner() {
|
|||
// Sticky in the content column (not fixed over the viewport) so it pushes
|
||||
// content down instead of covering the page title / quick search.
|
||||
return (
|
||||
<div role="status" className="sticky top-0 z-20 bg-danger px-4 py-2 text-center font-body text-sm text-surface">
|
||||
<div role="status" className="sticky top-0 z-20 bg-danger px-4 py-2 text-center font-body text-sm font-bold text-on-accent">
|
||||
You’re offline. Showing cached data where available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const bottomNavLinkClass = ({ isActive }) =>
|
||||
`flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 rounded-[20px] font-body text-[10.5px] font-bold transition-colors ${
|
||||
isActive ? 'bg-surface-sunken text-text-primary' : 'text-text-secondary'
|
||||
}`
|
||||
|
||||
function MoreMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
|
|
@ -162,23 +199,35 @@ function MoreMenu() {
|
|||
aria-haspopup="menu"
|
||||
className={bottomNavLinkClass({ isActive: isActive || open })}
|
||||
>
|
||||
<Icon name="more" />
|
||||
<Icon name="more" className="h-[22px] w-[22px]" strokeWidth={2.8} />
|
||||
More
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-full right-2 mb-2 w-48 rounded-lg border border-border bg-surface-raised p-1 shadow-lg"
|
||||
className="absolute bottom-full right-0 mb-3 w-56 rounded-3xl border border-border bg-surface-raised p-2 shadow-pop"
|
||||
>
|
||||
{MORE_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} role="menuitem" className={navLinkClass}>
|
||||
<Icon name={item.icon} />
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
role="menuitem"
|
||||
className={({ isActive: active }) =>
|
||||
`flex min-h-[48px] items-center gap-3 rounded-2xl px-2 font-body text-[15px] font-semibold text-text-primary ${
|
||||
active ? 'bg-surface-sunken' : 'hover:bg-surface-sunken'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-[10px] ${item.tile}`}>
|
||||
<Icon name={item.icon} className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<p className="border-t border-border px-3 pb-1 pt-2 font-body text-xs text-text-secondary">
|
||||
MyKindlebox v{APP_VERSION}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between border-t border-border pl-2 pt-1">
|
||||
<span className="font-body text-xs text-text-secondary">MyKindlebox v{APP_VERSION}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -197,31 +246,32 @@ function showsQuickSearch(pathname) {
|
|||
|
||||
function Layout() {
|
||||
const { pathname } = useLocation()
|
||||
const { user } = useAuth()
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
// Herd tab covers the herd list and rabbit pages, but not /herd/add, which
|
||||
// has its own tab — NavLink's prefix matching can't express that exclusion.
|
||||
const herdActive = pathname.startsWith('/herd') && pathname !== '/herd/add'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-surface text-text-primary">
|
||||
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col gap-1 border-r border-border p-4 md:flex">
|
||||
<p className="mb-4 px-3 font-display text-xl font-bold text-text-primary">MyKindlebox</p>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={navLinkClass}>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<p className="mt-auto px-3 font-body text-xs text-text-secondary">v{APP_VERSION}</p>
|
||||
</aside>
|
||||
<Sidebar herdActive={herdActive} />
|
||||
|
||||
{/* min-w-0 lets wide children (tables, the pedigree tree) scroll inside
|
||||
their own containers instead of stretching the whole page sideways. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<OfflineBanner />
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-[calc(5rem+env(safe-area-inset-bottom))] pt-4 md:px-8 md:pb-8 md:pt-6">
|
||||
<p className="mb-3 font-display text-lg font-bold text-text-primary md:hidden">MyKindlebox</p>
|
||||
<main className="mx-auto w-full max-w-[1180px] flex-1 px-4 pb-[calc(7rem+env(safe-area-inset-bottom))] pt-4 md:px-10 md:pb-14 md:pt-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3 md:hidden">
|
||||
<Link to="/" className="flex min-w-0 items-center gap-2.5">
|
||||
<BrandMark className="h-9 w-9" />
|
||||
<span className="truncate font-display text-lg font-extrabold text-text-primary">
|
||||
{settings?.rabbitry_name || 'MyKindlebox'}
|
||||
</span>
|
||||
</Link>
|
||||
<Link to="/settings" aria-label="Settings">
|
||||
<Avatar name={user?.username} />
|
||||
</Link>
|
||||
</div>
|
||||
{showsQuickSearch(pathname) && <QuickSearch />}
|
||||
<Outlet />
|
||||
</main>
|
||||
|
|
@ -229,32 +279,29 @@ function Layout() {
|
|||
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="fixed inset-x-0 bottom-0 z-20 flex border-t border-border bg-surface-raised pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
className="fixed inset-x-3 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-20 flex gap-0.5 rounded-[26px] border border-border bg-surface-raised p-1.5 shadow-pop md:hidden"
|
||||
>
|
||||
<NavLink to="/" end className={bottomNavLinkClass}>
|
||||
<Icon name="dashboard" />
|
||||
Dashboard
|
||||
<Icon name="dashboard" className="h-[22px] w-[22px]" />
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/herd" className={() => bottomNavLinkClass({ isActive: herdActive })}>
|
||||
<Icon name="herd" />
|
||||
<Icon name="herd" className="h-[22px] w-[22px]" />
|
||||
Herd
|
||||
</NavLink>
|
||||
<NavLink to="/herd/add" className={bottomNavLinkClass}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full ${
|
||||
isActive ? 'bg-accent-hover text-surface' : 'bg-accent text-surface'
|
||||
}`}
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</span>
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
<NavLink
|
||||
to="/herd/add"
|
||||
className={({ isActive }) =>
|
||||
`flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 rounded-[20px] font-body text-[10.5px] font-bold text-on-accent transition-colors ${
|
||||
isActive ? 'bg-accent-hover' : 'bg-accent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon name="add" className="h-6 w-6" strokeWidth={2.4} />
|
||||
Add
|
||||
</NavLink>
|
||||
<NavLink to="/litters" className={bottomNavLinkClass}>
|
||||
<Icon name="litters" />
|
||||
<Icon name="litters" className="h-[22px] w-[22px]" />
|
||||
Litters
|
||||
</NavLink>
|
||||
<MoreMenu key={pathname} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import Card from './Card'
|
||||
import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
|
||||
import Icon from './Icon'
|
||||
import { GESTATION_DAYS, addDays, daysUntil, formatDate } from '../lib/format'
|
||||
|
||||
// Kits drawn as a row of dots, cycling through the party hues — a litter's
|
||||
// size reads at a glance.
|
||||
const KIT_DOTS = ['bg-c1', 'bg-c2', 'bg-c3', 'bg-c4', 'bg-c5', 'bg-c6']
|
||||
|
||||
export function KitDots({ count, className = '' }) {
|
||||
const shown = Math.min(count ?? 0, 12)
|
||||
return (
|
||||
<span className={`flex flex-wrap gap-1 ${className}`} aria-hidden="true">
|
||||
{Array.from({ length: shown }).map((_, i) => (
|
||||
<span key={i} className={`h-4 w-4 rounded-full border-2 border-surface-raised ${KIT_DOTS[i % KIT_DOTS.length]}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// The whole card links to the litter; editing lives on the litter page.
|
||||
function LitterCard({ litter }) {
|
||||
|
|
@ -8,26 +23,45 @@ function LitterCard({ litter }) {
|
|||
const damName = litter.dam?.name ?? 'Unknown'
|
||||
const kitCount = litter.kit_count ?? 0
|
||||
const kindled = Boolean(litter.kindle_date)
|
||||
|
||||
// Once kindled, show the kindle date + kit count; an in-progress breeding shows
|
||||
// when it was bred and when it's due instead.
|
||||
const meta = kindled
|
||||
? `Kindled ${formatDate(litter.kindle_date)} • ${kitCount} ${kitCount === 1 ? 'kit' : 'kits'}`
|
||||
: `Bred ${formatDate(litter.breed_date)} • Due ${formatDate(addDays(litter.breed_date, GESTATION_DAYS))}`
|
||||
const due = litter.breed_date ? addDays(litter.breed_date, GESTATION_DAYS) : null
|
||||
const dueIn = due ? daysUntil(due) : null
|
||||
|
||||
return (
|
||||
<Link to={`/litters/${litter.id}`} className="block">
|
||||
<Card className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-surface-sunken">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-display text-base text-text-primary">
|
||||
{sireName} × {damName}
|
||||
<Link
|
||||
to={`/litters/${litter.id}`}
|
||||
className="lift flex items-center gap-4 rounded-3xl border border-border bg-surface-raised p-4 shadow-card"
|
||||
>
|
||||
<span
|
||||
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl ${
|
||||
kindled ? 'bg-c3-soft text-c3-ink' : 'bg-c2-soft text-c2-ink'
|
||||
}`}
|
||||
>
|
||||
<Icon name={kindled ? 'litters' : 'heart'} className="h-6 w-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-[17px] font-bold text-text-primary">
|
||||
{sireName} <span className="text-text-faint">×</span> {damName}
|
||||
</p>
|
||||
{kindled ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-body text-sm text-text-secondary">Kindled {formatDate(litter.kindle_date)}</span>
|
||||
<span className="flex items-center gap-2 font-body text-sm font-bold text-text-primary">
|
||||
<KitDots count={kitCount} />
|
||||
{kitCount} {kitCount === 1 ? 'kit' : 'kits'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
{due ? `Bred ${formatDate(litter.breed_date)} · Due ${formatDate(due)}` : 'Breeding date not recorded'}
|
||||
{dueIn != null && dueIn >= 0 && (
|
||||
<span className="ml-2 inline-block whitespace-nowrap rounded-full bg-c2-soft px-2 py-0.5 text-xs font-bold text-c2-ink">
|
||||
{dueIn === 0 ? 'Due today' : `${dueIn}d to go`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">{meta}</p>
|
||||
</div>
|
||||
<span aria-hidden="true" className="shrink-0 font-body text-lg text-text-secondary">
|
||||
›
|
||||
</span>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<Icon name="chevronRight" className="h-5 w-5 shrink-0 text-text-faint" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
// No horizontal padding: <Layout>'s <main> already supplies the page gutter,
|
||||
// so the header lines up with the content below it. On phones the action row
|
||||
// drops below the title so a multi-button action group can wrap freely.
|
||||
function PageHeader({ title, subtitle, action }) {
|
||||
// `kicker` is a small line above the title (a breadcrumb or context label).
|
||||
function PageHeader({ title, subtitle, kicker, action }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="flex flex-col gap-4 pt-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="break-words font-display text-2xl font-bold text-text-primary">{title}</h1>
|
||||
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
|
||||
{kicker && <div className="mb-1 font-body text-sm font-bold text-text-secondary">{kicker}</div>}
|
||||
<h1 className="break-words font-display text-3xl font-bold tracking-tight text-text-primary md:text-[38px] md:leading-[1.08]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-1.5 font-body text-[15px] text-text-secondary">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="sm:shrink-0">{action}</div>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { searchRabbits, getRabbitAncestors } from '../api'
|
||||
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||
import { formatWeight } from '../lib/format'
|
||||
import { CoatAvatar } from './CoatArt'
|
||||
import Icon from './Icon'
|
||||
import {
|
||||
ALL_SLOTS,
|
||||
GEN1,
|
||||
GEN2,
|
||||
GEN3,
|
||||
createInitialPedigreeState,
|
||||
resolveEffectiveNode,
|
||||
buildPedigreePayload,
|
||||
|
|
@ -48,9 +48,17 @@ const WIDE_TREE_QUERY = '(min-width: 1280px)'
|
|||
|
||||
const SLOT_BY_PATH = new Map(ALL_SLOTS.map((entry) => [entry.path.join('.'), entry]))
|
||||
|
||||
// Sire-side slots are blue, dam-side pink (the buck/doe hues used app-wide).
|
||||
function sideTone(path) {
|
||||
return path[path.length - 1] === 'sire' ? 'bg-c6-soft text-c6-ink' : 'bg-c2-soft text-c2-ink'
|
||||
}
|
||||
|
||||
// Node widths per generation in the wide bracket layout (px).
|
||||
const WIDTHS = [0, 210, 196, 184]
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 text-text-secondary" fill="currentColor" aria-label="Locked">
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0 opacity-70" 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"
|
||||
|
|
@ -136,11 +144,14 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
<div
|
||||
data-testid={`pedigree-node-${slot}`}
|
||||
data-locked="true"
|
||||
className="flex h-16 w-full items-center justify-between gap-2 rounded-md border border-border bg-surface-sunken px-3 py-2"
|
||||
title="Filled in from the linked parent's own pedigree"
|
||||
className={`flex min-h-[58px] w-full items-center justify-between gap-2 rounded-2xl px-3 py-2 ${
|
||||
node.mode === 'unknown' ? 'border-2 border-dashed border-border text-text-faint' : `${sideTone(path)} opacity-75`
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<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-[11px] font-bold opacity-80">{label}</p>
|
||||
<p className="truncate font-body text-sm font-semibold">{text}</p>
|
||||
</div>
|
||||
<LockIcon />
|
||||
</div>
|
||||
|
|
@ -194,45 +205,49 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
setManualForm(EMPTY_MANUAL_FORM)
|
||||
}
|
||||
}}
|
||||
className={`flex min-h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border bg-surface-raised px-3 py-2 text-left hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent ${
|
||||
flagged ? 'border-accent-warm ring-1 ring-accent-warm' : 'border-border'
|
||||
}`}
|
||||
aria-expanded={open}
|
||||
className={`flex min-h-[58px] w-full min-w-0 flex-col items-start justify-center gap-px overflow-hidden rounded-2xl px-3 py-2 text-left transition-[transform,box-shadow] hover:-translate-y-0.5 focus:outline-none ${
|
||||
node.mode === 'unknown'
|
||||
? 'border-2 border-dashed border-border-strong bg-surface-raised text-text-secondary hover:border-accent hover:text-text-primary'
|
||||
: sideTone(path)
|
||||
} ${flagged ? 'ring-2 ring-c5 ring-offset-2 ring-offset-surface-raised' : ''} ${open ? 'shadow-pop' : ''}`}
|
||||
>
|
||||
<span className="flex w-full items-center gap-1.5 font-body text-xs text-text-secondary">
|
||||
<span className="flex w-full items-center gap-1.5 font-body text-[11px] font-bold opacity-80">
|
||||
<span className="truncate">{label}</span>
|
||||
{flagged && (
|
||||
<span className="shrink-0 rounded bg-accent-warm px-1.5 font-medium text-on-accent-warm">Check</span>
|
||||
<span className="shrink-0 rounded-full bg-c5 px-1.5 font-extrabold text-[#1c1530]">Check</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-full truncate font-body text-sm ${node.mode === 'unknown' ? 'text-text-secondary' : 'text-text-primary'}`}
|
||||
>
|
||||
{displayText}
|
||||
<span className="flex w-full items-center gap-1 truncate font-body text-sm font-extrabold">
|
||||
{node.mode === 'unknown' && <Icon name="add" className="h-3.5 w-3.5 shrink-0" strokeWidth={2.6} />}
|
||||
<span className="truncate">{node.mode === 'unknown' ? 'Unknown — choose' : displayText}</span>
|
||||
</span>
|
||||
{detailText && <span className="w-full truncate font-body text-xs text-text-secondary">{detailText}</span>}
|
||||
{detailText && <span className="w-full truncate font-body text-xs opacity-85">{detailText}</span>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<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 ${
|
||||
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
className={`absolute left-0 z-30 w-full min-w-[17rem] max-w-sm rounded-3xl border border-border bg-surface-raised p-2.5 text-text-primary shadow-pop ${
|
||||
dropUp ? 'bottom-full mb-2' : 'top-full mt-2'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
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'}`}
|
||||
>
|
||||
Search existing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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'}`}
|
||||
>
|
||||
Enter manually
|
||||
</button>
|
||||
<div className="mb-2 flex gap-1 rounded-full bg-surface-sunken p-1">
|
||||
{[
|
||||
{ value: 'search', label: 'From your herd' },
|
||||
{ value: 'manual', label: 'Type it in' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setMode(opt.value)}
|
||||
aria-pressed={mode === opt.value}
|
||||
className={`min-h-[36px] flex-1 rounded-full px-2 font-body text-xs font-bold transition-colors ${
|
||||
mode === opt.value ? 'bg-surface-raised text-text-primary shadow-card' : 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'search' && (
|
||||
|
|
@ -244,10 +259,10 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
value={query}
|
||||
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-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
||||
placeholder="Search by name or ear #…"
|
||||
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<ul className="mt-1 max-h-40 overflow-auto">
|
||||
<ul className="mt-1.5 max-h-52 overflow-auto">
|
||||
{results.map((rabbit, index) => (
|
||||
<li key={rabbit.id}>
|
||||
<button
|
||||
|
|
@ -261,15 +276,22 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
close,
|
||||
})
|
||||
}
|
||||
className="w-full rounded px-2 py-2 text-left text-sm hover:bg-surface-sunken md:py-1"
|
||||
className="flex w-full items-center gap-2.5 rounded-2xl px-2 py-1.5 text-left hover:bg-surface-sunken focus:bg-surface-sunken"
|
||||
>
|
||||
{rabbit.name}
|
||||
{rabbit.farm ? ` (${rabbit.farm})` : ''}
|
||||
<CoatAvatar rabbit={rabbit} className="h-8 w-8" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-body text-sm font-bold">{rabbit.name}</span>
|
||||
{(rabbit.farm || rabbit.ear_number) && (
|
||||
<span className="block truncate font-body text-xs text-text-secondary">
|
||||
{[rabbit.farm, rabbit.ear_number && `#${rabbit.ear_number}`].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{query && results.length === 0 && (
|
||||
<li className="px-2 py-1 text-sm text-text-secondary">No matches</li>
|
||||
<li className="px-2 py-2 font-body text-sm text-text-secondary">No matches — try “Type it in”.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -283,21 +305,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-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
||||
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<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-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent md:py-1"
|
||||
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<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-2 text-sm uppercase md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-mono text-sm uppercase focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div>
|
||||
<label className="block px-0.5 font-body text-xs text-text-secondary">
|
||||
|
|
@ -308,7 +330,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-2 text-sm md:py-1 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="mt-0.5 w-full rounded-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -318,7 +340,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-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-xl border-[1.5px] border-border px-3 py-2 font-body text-sm focus:border-accent focus:outline-none 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">
|
||||
|
|
@ -340,7 +362,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-2 text-sm font-medium text-surface disabled:opacity-50 md:py-1.5 md:text-xs"
|
||||
className="min-h-[40px] w-full rounded-full bg-accent px-3 font-body text-sm font-bold text-on-accent disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
|
@ -350,7 +372,7 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
<button
|
||||
type="button"
|
||||
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-1.5 min-h-[38px] w-full rounded-full px-3 text-center font-body text-xs font-bold text-text-secondary hover:bg-surface-sunken"
|
||||
>
|
||||
Mark as Unknown
|
||||
</button>
|
||||
|
|
@ -360,22 +382,36 @@ function AncestorSlot({ path, slot, label, state, setSlotState, cascadeCache, fe
|
|||
)
|
||||
}
|
||||
|
||||
function Generation({ entries, state, setSlotState, cascadeCache, fetchCascade, flaggedSlots }) {
|
||||
// One slot plus (in the wide layout) its own sire/dam, joined by the bracket
|
||||
// lines drawn in index.css (.ped-*).
|
||||
function Branch({ 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 min-w-0 flex-1 flex-col justify-around gap-3">
|
||||
{entries.map(({ path, slot, label }) => (
|
||||
<div className="ped-br">
|
||||
<div className={`ped-nw ${childPaths.length ? 'has' : ''}`} style={{ width: WIDTHS[path.length] }}>
|
||||
<AncestorSlot
|
||||
key={slot}
|
||||
path={path}
|
||||
slot={slot}
|
||||
label={label}
|
||||
state={state}
|
||||
setSlotState={setSlotState}
|
||||
cascadeCache={cascadeCache}
|
||||
fetchCascade={fetchCascade}
|
||||
flagged={flaggedSlots.has(slot)}
|
||||
path={entry.path}
|
||||
slot={entry.slot}
|
||||
label={entry.label}
|
||||
state={slotProps.state}
|
||||
setSlotState={slotProps.setSlotState}
|
||||
cascadeCache={slotProps.cascadeCache}
|
||||
fetchCascade={slotProps.fetchCascade}
|
||||
flagged={slotProps.flaggedSlots.has(entry.slot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{childPaths.length > 0 && (
|
||||
<div className="ped-pair">
|
||||
{childPaths.map((childPath) => (
|
||||
<Branch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -402,7 +438,7 @@ function StackedBranch({ path, slotProps }) {
|
|||
flagged={slotProps.flaggedSlots.has(entry.slot)}
|
||||
/>
|
||||
{childPaths.length > 0 && (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
|
||||
<div className="ml-2 flex flex-col gap-2 border-l-[2.5px] border-border-strong pl-3">
|
||||
{childPaths.map((childPath) => (
|
||||
<StackedBranch key={childPath.join('.')} path={childPath} slotProps={slotProps} />
|
||||
))}
|
||||
|
|
@ -413,6 +449,9 @@ function StackedBranch({ path, slotProps }) {
|
|||
}
|
||||
|
||||
function PedigreeTree({
|
||||
// Exposes setParent(slot, rabbit) so the page can fill the sire/dam from
|
||||
// outside the tree (Add Rabbit's suggested parents).
|
||||
ref,
|
||||
subjectName = 'New Rabbit',
|
||||
initialState,
|
||||
initialCascade,
|
||||
|
|
@ -445,6 +484,13 @@ function PedigreeTree({
|
|||
.catch(() => setCascadeCache((prev) => new Map(prev).set(id, null)))
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setParent(slot, rabbit) {
|
||||
setSlotState(slot, { mode: 'linked', id: rabbit.id, name: rabbit.name })
|
||||
fetchCascade(rabbit.id)
|
||||
},
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.(buildPedigreePayload(state, cascadeCache), buildPedigreeSummary(state, cascadeCache))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -461,13 +507,15 @@ function PedigreeTree({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const slotProps = { state, setSlotState, cascadeCache, fetchCascade, flaggedSlots }
|
||||
|
||||
if (!wide) {
|
||||
const slotProps = { state, setSlotState, cascadeCache, fetchCascade, flaggedSlots }
|
||||
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">
|
||||
<section key={slot} className="rounded-3xl border border-border bg-surface-raised p-3.5 shadow-card">
|
||||
<h3 className="mb-2.5 flex items-center gap-2 px-1 font-display text-base font-bold text-text-primary">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${slot === 'sire' ? 'bg-c6' : 'bg-c2'}`} aria-hidden="true" />
|
||||
{slot === 'sire' ? "Sire's side" : "Dam's side"}
|
||||
</h3>
|
||||
<StackedBranch path={path} slotProps={slotProps} />
|
||||
|
|
@ -477,14 +525,22 @@ function PedigreeTree({
|
|||
)
|
||||
}
|
||||
|
||||
// No overflow clipping here: a slot's picker floats out over the card (each
|
||||
// picker flips upward on its own when there isn't room below).
|
||||
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 className="w-full rounded-3xl border border-border bg-surface-raised p-5 shadow-card">
|
||||
<div className="ped-br">
|
||||
<div className="ped-nw has" style={{ width: 150 }}>
|
||||
<div className="flex min-h-[72px] items-center justify-center rounded-2xl bg-text-primary px-3 text-center">
|
||||
<span className="truncate font-display text-base font-bold text-surface">{subjectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ped-pair">
|
||||
{GEN1.map(({ path }) => (
|
||||
<Branch key={path.join('.')} path={path} slotProps={slotProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} flaggedSlots={flaggedSlots} />
|
||||
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} flaggedSlots={flaggedSlots} />
|
||||
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} flaggedSlots={flaggedSlots} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,52 +4,68 @@ import { useElementWidth } from '../hooks/useElementWidth'
|
|||
|
||||
const LABEL_BY_PATH = new Map([...GEN1, ...GEN2, ...GEN3].map((entry) => [entry.path.join('.'), entry.label]))
|
||||
|
||||
function AncestorNodeView({ path, label, ancestors }) {
|
||||
// Sire-side slots are blue, dam-side pink — the same buck/doe hues as the
|
||||
// sex badges, so the two halves of the tree read apart at a glance.
|
||||
function toneFor(path) {
|
||||
return path[path.length - 1] === 'sire' ? 'bg-c6-soft text-c6-ink' : 'bg-c2-soft text-c2-ink'
|
||||
}
|
||||
|
||||
// Node widths per generation in the bracket layout (px).
|
||||
const WIDTHS = [0, 196, 184, 172]
|
||||
|
||||
function AncestorNodeView({ path, ancestors, compact }) {
|
||||
const node = getAncestorAtPath(ancestors, path)
|
||||
const label = LABEL_BY_PATH.get(path.join('.'))
|
||||
const base = `flex w-full min-w-0 flex-col justify-center gap-px overflow-hidden rounded-2xl px-3 ${compact ? 'min-h-[50px] py-1.5' : 'min-h-[58px] py-2'}`
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-sunken px-3 py-2">
|
||||
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
|
||||
<span className="w-full truncate font-body text-sm text-text-secondary italic">Unknown</span>
|
||||
<div className={`${base} border-2 border-dashed border-border text-text-faint`}>
|
||||
<span className="truncate font-body text-[11px] font-bold">{label}</span>
|
||||
<span className="truncate font-body text-sm font-semibold">Unknown</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.id != null) {
|
||||
const meta = [node.farm, node.ear_number && `#${node.ear_number}`].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<Link
|
||||
to={`/herd/${node.id}`}
|
||||
className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-raised px-3 py-2 hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className={`${base} ${toneFor(path)} transition-transform hover:-translate-y-0.5`}
|
||||
title={[node.name, meta].filter(Boolean).join(' · ')}
|
||||
>
|
||||
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
|
||||
<span className="w-full truncate font-body text-sm text-text-primary">
|
||||
{node.name}
|
||||
{node.farm ? ` (${node.farm})` : ''}
|
||||
</span>
|
||||
{node.ear_number ? (
|
||||
<span className="w-full truncate font-body text-xs text-text-secondary">Ear #{node.ear_number}</span>
|
||||
) : null}
|
||||
<span className="truncate font-body text-[11px] font-bold opacity-80">{label}</span>
|
||||
<span className="truncate font-body text-sm font-extrabold">{node.name}</span>
|
||||
{meta && !compact && <span className="truncate font-body text-xs opacity-85">{meta}</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Free-text ancestor (no record) — same tint, lighter weight.
|
||||
return (
|
||||
<div className="flex h-16 w-full min-w-0 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-md border border-border bg-surface-sunken px-3 py-2">
|
||||
<span className="w-full truncate font-body text-xs text-text-secondary">{label}</span>
|
||||
<span className="w-full truncate font-body text-sm text-text-secondary italic">
|
||||
{formatManualAncestorDisplay(node.name)}
|
||||
</span>
|
||||
<div className={`${base} ${toneFor(path)} opacity-80`}>
|
||||
<span className="truncate font-body text-[11px] font-bold">{label}</span>
|
||||
<span className="truncate font-body text-sm font-semibold italic">{formatManualAncestorDisplay(node.name)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Generation({ entries, ancestors }) {
|
||||
// One node plus (if deeper generations are shown) its two parents, joined by
|
||||
// the bracket lines drawn in index.css (.ped-*).
|
||||
function Branch({ path, ancestors, depth }) {
|
||||
const hasParents = path.length < depth
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-around gap-3">
|
||||
{entries.map(({ path, slot, label }) => (
|
||||
<AncestorNodeView key={slot} path={path} label={label} ancestors={ancestors} />
|
||||
))}
|
||||
<div className="ped-br">
|
||||
<div className={`ped-nw ${hasParents ? 'has' : ''}`} style={{ width: WIDTHS[path.length] }}>
|
||||
<AncestorNodeView path={path} ancestors={ancestors} compact={path.length >= 3} />
|
||||
</div>
|
||||
{hasParents && (
|
||||
<div className="ped-pair">
|
||||
<Branch path={[...path, 'sire']} ancestors={ancestors} depth={depth} />
|
||||
<Branch path={[...path, 'dam']} ancestors={ancestors} depth={depth} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -59,9 +75,9 @@ function Generation({ entries, ancestors }) {
|
|||
function StackedBranch({ path, ancestors, depth }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<AncestorNodeView path={path} label={LABEL_BY_PATH.get(path.join('.'))} ancestors={ancestors} />
|
||||
<AncestorNodeView path={path} ancestors={ancestors} compact={path.length >= 2} />
|
||||
{path.length < depth && (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l-2 border-border pl-3">
|
||||
<div className="ml-2 flex flex-col gap-2 border-l-[2.5px] border-border-strong pl-3">
|
||||
<StackedBranch path={[...path, 'sire']} ancestors={ancestors} depth={depth} />
|
||||
<StackedBranch path={[...path, 'dam']} ancestors={ancestors} depth={depth} />
|
||||
</div>
|
||||
|
|
@ -70,28 +86,28 @@ function StackedBranch({ path, ancestors, depth }) {
|
|||
)
|
||||
}
|
||||
|
||||
// `generations` controls how many ancestor columns to render. The detail page
|
||||
// uses 2 (parents + grandparents) to keep the inline tree compact; the full
|
||||
// 3-generation tree lives on the dedicated print page.
|
||||
// `generations` controls how many ancestor columns to render (the print page
|
||||
// has the full 4-generation slip).
|
||||
function PedigreeTreeReadOnly({ subjectName, ancestors, generations = 3 }) {
|
||||
// Each generation column is ~176px (incl. gap); narrower min-width for fewer
|
||||
// generations lets the tree fit its column without horizontal scrolling.
|
||||
const minWidth = generations >= 3 ? 720 : 544
|
||||
const minWidth = generations >= 3 ? 860 : 620
|
||||
const [ref, width] = useElementWidth()
|
||||
const wide = width >= minWidth
|
||||
|
||||
return (
|
||||
// The scroll container fills (and shrinks with) its column. When the column
|
||||
// is too narrow for the bracket layout, the stacked layout is used instead.
|
||||
<div ref={ref} className="w-full overflow-x-auto p-4">
|
||||
<div ref={ref} className="w-full overflow-x-auto p-5">
|
||||
{wide ? (
|
||||
<div className="flex items-center gap-4" style={{ minWidth }}>
|
||||
<div className="flex h-16 w-28 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 className="ped-br">
|
||||
<div className="ped-nw has" style={{ width: 150 }}>
|
||||
<div className="flex min-h-[64px] items-center justify-center rounded-2xl bg-text-primary px-3 text-center">
|
||||
<span className="truncate font-display text-base font-bold text-surface">{subjectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ped-pair">
|
||||
<Branch path={['sire']} ancestors={ancestors} depth={generations} />
|
||||
<Branch path={['dam']} ancestors={ancestors} depth={generations} />
|
||||
</div>
|
||||
<Generation entries={GEN1} ancestors={ancestors} />
|
||||
{generations >= 2 && <Generation entries={GEN2} ancestors={ancestors} />}
|
||||
{generations >= 3 && <Generation entries={GEN3} ancestors={ancestors} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { quickSearchRabbits } from '../api'
|
||||
import { handleResultKeyDown, handleSearchInputKeyDown } from '../lib/keyboardNav'
|
||||
import Badge from './Badge'
|
||||
import { CoatAvatar } from './CoatArt'
|
||||
import Icon from './Icon'
|
||||
|
||||
// The fields the quick-search can target. Keys match the backend's
|
||||
// `?fields=` values and the `matched_fields` it returns; `label` is the
|
||||
|
|
@ -110,7 +112,8 @@ function QuickSearch() {
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="relative mb-6 max-w-xl">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex min-h-[48px] items-center gap-2.5 rounded-full border-[1.5px] border-border bg-surface-raised pl-4 pr-1.5 text-text-faint shadow-card transition-colors focus-within:border-accent">
|
||||
<Icon name="search" className="h-[18px] w-[18px] shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
|
@ -123,34 +126,33 @@ function QuickSearch() {
|
|||
onKeyDown={(e) =>
|
||||
handleSearchInputKeyDown(e, { containerEl: containerRef.current, close })
|
||||
}
|
||||
placeholder="Quick search herd…"
|
||||
placeholder="Search name, ear #, farm or color"
|
||||
aria-label="Quick search herd"
|
||||
className="w-full rounded-md border border-border bg-surface-raised px-3 py-2 font-body text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
className="min-w-0 flex-1 !bg-transparent py-2 font-body text-[15px] font-medium text-text-primary focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
aria-expanded={filtersOpen}
|
||||
aria-label="Choose search filters"
|
||||
className={`shrink-0 rounded-md border px-3 py-2 font-body text-sm ${
|
||||
filtersOpen
|
||||
? 'border-accent text-accent'
|
||||
: 'border-border text-text-secondary hover:border-accent'
|
||||
title="Search filters"
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors ${
|
||||
filtersOpen ? 'bg-c1-soft text-c1-ink' : 'text-text-secondary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
Filters
|
||||
<Icon name="filter" className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="absolute right-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-surface-raised p-2 shadow-sm">
|
||||
<p className="px-1 pb-1 font-body text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
<div className="absolute right-0 top-full z-20 mt-2 w-56 rounded-2xl border border-border bg-surface-raised p-2 shadow-pop">
|
||||
<p className="px-2 pb-1 pt-1 font-body text-xs font-bold text-text-secondary">
|
||||
Search in
|
||||
</p>
|
||||
{SEARCH_FIELDS.map((field) => (
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-surface-sunken"
|
||||
className="flex min-h-[40px] cursor-pointer items-center gap-2.5 rounded-xl px-2 font-body text-sm font-semibold hover:bg-surface-sunken"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -165,15 +167,15 @@ function QuickSearch() {
|
|||
)}
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute left-0 top-full z-10 mt-1 w-full rounded-md border border-border bg-surface-raised shadow-sm">
|
||||
<div className="absolute left-0 top-full z-10 mt-2 w-full overflow-hidden rounded-3xl border border-border bg-surface-raised shadow-pop">
|
||||
{selectedFields.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-text-secondary">
|
||||
<p className="px-4 py-3 font-body text-sm text-text-secondary">
|
||||
No filters selected.
|
||||
</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-text-secondary">No matches</p>
|
||||
<p className="px-4 py-3 font-body text-sm text-text-secondary">No matches</p>
|
||||
) : (
|
||||
<ul className="max-h-80 overflow-auto py-1">
|
||||
<ul className="max-h-96 overflow-auto p-1.5">
|
||||
{results.map((result, index) => (
|
||||
<li key={result.id}>
|
||||
<button
|
||||
|
|
@ -187,9 +189,11 @@ function QuickSearch() {
|
|||
close,
|
||||
})
|
||||
}
|
||||
className="block w-full px-3 py-2 text-left hover:bg-surface-sunken"
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-2 text-left hover:bg-surface-sunken focus:bg-surface-sunken"
|
||||
>
|
||||
<span className="font-body text-sm text-text-primary">
|
||||
<CoatAvatar rabbit={result} className="h-10 w-10" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-body text-sm font-bold text-text-primary">
|
||||
{result.name}
|
||||
{result.farm ? (
|
||||
<span className="text-text-secondary"> · {result.farm}</span>
|
||||
|
|
@ -202,13 +206,13 @@ function QuickSearch() {
|
|||
<Badge status={result.status} />
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
<span className="mt-0.5 flex flex-wrap gap-1">
|
||||
{(result.matched_fields ?? []).map((field) => {
|
||||
const value = matchedValue(result, field)
|
||||
return (
|
||||
<span
|
||||
key={field}
|
||||
className="rounded bg-surface-sunken px-1.5 py-0.5 text-xs text-text-secondary"
|
||||
className="rounded-full bg-surface-sunken px-2 py-0.5 font-body text-[11px] font-semibold text-text-secondary"
|
||||
>
|
||||
{MATCH_LABELS[field] ?? field}
|
||||
{value ? `: ${value}` : ''}
|
||||
|
|
@ -216,6 +220,7 @@ function QuickSearch() {
|
|||
)
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function RabbitAutocomplete({ sex, value, onChange, placeholder = 'Search by nam
|
|||
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute left-0 z-30 w-full rounded-md border border-border bg-surface-raised p-2 shadow-lg ${
|
||||
className={`absolute left-0 z-30 w-full rounded-2xl border border-border bg-surface-raised p-2 shadow-pop ${
|
||||
dropUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,49 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import Badge from './Badge'
|
||||
import Card from './Card'
|
||||
import Silhouette from './Silhouette'
|
||||
import { SERVER_BASE } from '../api'
|
||||
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
|
||||
import Badge, { SexBadge } from './Badge'
|
||||
import { CoatPanel } from './CoatArt'
|
||||
import { displayColor, formatAge, formatWeight } from '../lib/format'
|
||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||
|
||||
function RabbitCard({ rabbit }) {
|
||||
const weightUnit = useWeightUnit()
|
||||
const age = formatAge(rabbit.dob)
|
||||
const age = rabbit.dob ? formatAge(rabbit.dob) : null
|
||||
const weight = formatWeight(rabbit.weight_lbs, weightUnit)
|
||||
const subtitleParts = [rabbit.color_name, SEX_LABELS[rabbit.sex] ?? rabbit.sex].filter(Boolean)
|
||||
const meta = [displayColor(rabbit.color_name), age].filter(Boolean).join(' · ')
|
||||
|
||||
return (
|
||||
<Link to={`/herd/${rabbit.id}`}>
|
||||
<Card className="flex h-full flex-col overflow-hidden transition-colors hover:bg-surface-sunken">
|
||||
<div className="relative flex aspect-square items-center justify-center bg-surface-sunken">
|
||||
{rabbit.photo_path ? (
|
||||
<img
|
||||
src={`${SERVER_BASE}${rabbit.photo_path}`}
|
||||
alt={rabbit.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Silhouette />
|
||||
)}
|
||||
{/* Active is the norm, so only the exceptions (For Sale, Sold, …) are
|
||||
badged — enough to tell them apart when the Herd filter is "All". */}
|
||||
{rabbit.status !== 'active' && (
|
||||
<span className="absolute left-2 top-2">
|
||||
<Badge status={rabbit.status} />
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/herd/${rabbit.id}`}
|
||||
className="lift flex h-full flex-col gap-2.5 rounded-3xl border border-border bg-surface-raised p-2.5 shadow-card"
|
||||
>
|
||||
<div className="relative">
|
||||
<CoatPanel rabbit={rabbit} className="aspect-[5/4] rounded-[18px]" />
|
||||
{/* Active is the norm, so only the exceptions (For Sale, Sold, …) are
|
||||
badged — enough to tell them apart when the Herd filter is "All". */}
|
||||
{rabbit.status !== 'active' && (
|
||||
<span className="absolute left-2.5 top-2.5">
|
||||
<Badge status={rabbit.status} className="shadow-card" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 px-2 pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate font-display text-[17px] font-bold text-text-primary">{rabbit.name}</p>
|
||||
<SexBadge sex={rabbit.sex} short />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
<p className="truncate font-display text-base text-text-primary">{rabbit.name}</p>
|
||||
{subtitleParts.length > 0 && (
|
||||
<p className="truncate font-body text-sm text-text-secondary">
|
||||
{subtitleParts.join(' • ')}
|
||||
</p>
|
||||
)}
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{age}
|
||||
{weight ? ` • ${weight}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
{meta && <p className="truncate font-body text-[13px] text-text-secondary">{meta}</p>}
|
||||
{(rabbit.ear_number || weight) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{rabbit.ear_number ? (
|
||||
<span className="truncate rounded-lg bg-surface-sunken px-2 py-0.5 font-mono text-xs font-semibold text-text-primary">
|
||||
{rabbit.ear_number}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{weight && <span className="shrink-0 font-body text-[13px] font-semibold text-text-secondary">{weight}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function RabbitPicker({ onSelect, onClose, alreadyEnteredIds }) {
|
|||
>
|
||||
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg border border-border bg-surface-raised shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Add Rabbit</h2>
|
||||
<h2 className="font-display text-lg font-bold text-text-primary">Add Rabbit</h2>
|
||||
<button type="button" onClick={onClose} className="font-body text-sm text-text-secondary hover:text-text-primary">
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -16,22 +16,22 @@ const EVENT_TYPES = {
|
|||
birth: {
|
||||
icon: <path d="M12 3c3.5 0 6 5 6 10a6 6 0 0 1-12 0c0-5 2.5-10 6-10Z" />,
|
||||
order: 0,
|
||||
dot: 'bg-accent text-surface',
|
||||
dot: 'bg-c1-soft text-c1-ink',
|
||||
},
|
||||
acquired: {
|
||||
icon: <path d="M4 11.5 12 4l8 7.5M6.5 10v10h11V10" />,
|
||||
order: 1,
|
||||
dot: 'bg-accent-warm text-on-accent-warm',
|
||||
dot: 'bg-c5-soft text-c5-ink',
|
||||
},
|
||||
breeding: {
|
||||
icon: <path d="M12 20s-7.5-4.6-7.5-10.2A4.2 4.2 0 0 1 12 7.4a4.2 4.2 0 0 1 7.5 2.4C19.5 15.4 12 20 12 20Z" />,
|
||||
order: 2,
|
||||
dot: 'bg-surface-sunken text-text-primary',
|
||||
dot: 'bg-c2-soft text-c2-ink',
|
||||
},
|
||||
kindle: {
|
||||
icon: <path d="M4 11h16v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7ZM8 7.5C8 6.1 9.8 5 12 5s4 1.1 4 2.5" />,
|
||||
order: 3,
|
||||
dot: 'bg-accent text-surface',
|
||||
dot: 'bg-c3-soft text-c3-ink',
|
||||
},
|
||||
show: {
|
||||
icon: (
|
||||
|
|
@ -41,7 +41,7 @@ const EVENT_TYPES = {
|
|||
</>
|
||||
),
|
||||
order: 4,
|
||||
dot: 'bg-accent-warm text-on-accent-warm',
|
||||
dot: 'bg-c4-soft text-c4-ink',
|
||||
},
|
||||
sale: {
|
||||
icon: (
|
||||
|
|
@ -51,7 +51,7 @@ const EVENT_TYPES = {
|
|||
</>
|
||||
),
|
||||
order: 5,
|
||||
dot: 'bg-accent text-surface',
|
||||
dot: 'bg-c6-soft text-c6-ink',
|
||||
},
|
||||
other: {
|
||||
icon: (
|
||||
|
|
@ -408,7 +408,7 @@ function RabbitTimeline({ rabbit }) {
|
|||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-display text-lg text-text-primary">Timeline</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Timeline</h2>
|
||||
{formMode === null && (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setFormMode('add')}>
|
||||
Add Event
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function ReportModal({ eventId, onClose }) {
|
|||
>
|
||||
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-surface-raised shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Entries Report</h2>
|
||||
<h2 className="font-display text-lg font-bold text-text-primary">Entries Report</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={copy} disabled={isLoading || isError || !report}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
function Silhouette({ className = 'h-12 w-12' }) {
|
||||
return (
|
||||
<svg viewBox="0 0 64 64" className={`${className} text-text-secondary`} fill="currentColor">
|
||||
<path d="M21 10c-2 0-3 3-3 7 0 3 1 6 3 8-4 2-7 6-7 12v8c0 3 2 5 5 5h26c3 0 5-2 5-5v-8c0-6-3-10-7-12 2-2 3-5 3-8 0-4-1-7-3-7-2 0-4 4-4 8 0 1 0 2 1 3-2-1-5-2-8-2s-6 1-8 2c1-1 1-2 1-3 0-4-2-8-4-8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default Silhouette
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
// 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.
|
||||
// Next/Save is always in thumb reach. On phones it floats just above the
|
||||
// bottom nav (see Layout.jsx); on desktop it's a floating rounded bar.
|
||||
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 className="sticky bottom-[calc(5.75rem+env(safe-area-inset-bottom))] z-10 md:bottom-4">
|
||||
<div className="flex items-center justify-end gap-3 rounded-full border border-border bg-surface-raised p-2 shadow-pop">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getSettings, updateSettings } from '../api'
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||
import { applyTheme, composeTheme, parseTheme } from '../lib/theme'
|
||||
import Icon from './Icon'
|
||||
|
||||
// One-tap light/dark flip for the sidebar. It keeps the palette and pins the
|
||||
// mode to the opposite of what's showing now (so "Auto" becomes an explicit
|
||||
// Light/Dark); Settings still offers Auto. Saved straight to the user's
|
||||
// settings so it follows them across devices.
|
||||
function ThemeToggle({ className = '' }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
const { palette, mode } = parseTheme(settings?.theme)
|
||||
const isDark = mode === 'dark' || (mode === 'system' && systemDark)
|
||||
|
||||
async function toggle() {
|
||||
const next = composeTheme(palette, isDark ? 'light' : 'dark')
|
||||
applyTheme(next)
|
||||
queryClient.setQueryData(['settings'], (prev) => (prev ? { ...prev, theme: next } : prev))
|
||||
try {
|
||||
await updateSettings({ theme: next })
|
||||
} catch {
|
||||
// Offline or failed: the local preview stays; the next settings load
|
||||
// reconciles it.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl text-text-secondary transition-colors hover:bg-surface-raised hover:text-text-primary ${className}`}
|
||||
>
|
||||
<Icon name={isDark ? 'sun' : 'moon'} className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
||||
|
|
@ -74,7 +74,7 @@ export default function UsersCard({ onToast }) {
|
|||
return (
|
||||
<Card className="space-y-4 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Users</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Users</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
Everyone here shares the same herd and can manage users. Theme and weight display are
|
||||
per-user; the rest of Settings is shared.
|
||||
|
|
@ -138,7 +138,7 @@ export default function UsersCard({ onToast }) {
|
|||
)}
|
||||
|
||||
<form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4">
|
||||
<h3 className="font-display text-base text-text-primary">Add a user</h3>
|
||||
<h3 className="font-display text-base font-bold text-text-primary">Add a user</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Username">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -2,158 +2,158 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Theme palettes. The Tailwind color tokens (tailwind.config.js) resolve to
|
||||
these CSS variables, so setting `data-theme` on <html> re-themes the whole
|
||||
app. `:root` is the default (Classic Cream) palette, also used when no/an
|
||||
unknown data-theme is set. Each named theme overrides the same ten tokens.
|
||||
Keep this list in sync with the registry in src/lib/theme.js. */
|
||||
/* Theme palettes ("Confetti" design). The Tailwind color tokens
|
||||
(tailwind.config.js) resolve to these CSS variables, so setting
|
||||
`data-theme` on <html> re-themes the whole app with no markup changes.
|
||||
|
||||
A theme is a palette × a mode. lib/theme.js resolves the user's choice
|
||||
(e.g. `confetti-system`) to one of the four concrete blocks below
|
||||
(`confetti-light`, `confetti-dark`, `meadow-light`, `meadow-dark`) — the
|
||||
`system` mode follows prefers-color-scheme. `:root` doubles as Confetti
|
||||
light so an unknown/missing attribute is never unstyled. Keep these ids in
|
||||
sync with src/lib/theme.js and the inline script in index.html.
|
||||
|
||||
Token groups:
|
||||
- surface / raised / sunken / border / text-*: the calm neutral base.
|
||||
- accent (+ hover, on-accent): the one primary action color.
|
||||
- accent-warm / danger: status colors kept from the original token set.
|
||||
- c1…c6: six "party" hues, each with a strong fill, a soft tint and an
|
||||
ink that's readable on that tint. Semantic use across the app:
|
||||
c1 violet herd · c2 pink does · c3 orange litters/kits ·
|
||||
c4 teal for sale · c5 yellow birthdays/coming up · c6 blue bucks. */
|
||||
:root,
|
||||
[data-theme='classic'] {
|
||||
--color-surface: #f7f3ec;
|
||||
[data-theme='confetti-light'] {
|
||||
--color-surface: #faf8ff;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-surface-sunken: #ede8de;
|
||||
--color-border: #d4c9b4;
|
||||
--color-text-primary: #1c1a16;
|
||||
--color-text-secondary: #6b6354;
|
||||
--color-accent: #4a6741;
|
||||
--color-accent-hover: #3a5233;
|
||||
--color-accent-warm: #c47d2e;
|
||||
--color-danger: #b34040;
|
||||
--color-surface-sunken: #f3effb;
|
||||
--color-border: #eae4f4;
|
||||
--color-border-strong: #d9d1ea;
|
||||
--color-text-primary: #1c1530;
|
||||
--color-text-secondary: #5f5773;
|
||||
--color-text-faint: #8f88a1;
|
||||
--color-accent: #6a48ee;
|
||||
--color-accent-hover: #5a38de;
|
||||
--color-on-accent: #ffffff;
|
||||
--color-accent-warm: #ff8a3d;
|
||||
--color-on-accent-warm: #1c1530;
|
||||
--color-danger: #d42a5b;
|
||||
|
||||
--c1: #6a48ee; --c1-soft: #ede8ff; --c1-ink: #4f31cc;
|
||||
--c2: #ee4e8b; --c2-soft: #fde5ef; --c2-ink: #b92763;
|
||||
--c3: #ff8a3d; --c3-soft: #ffebdd; --c3-ink: #a84b0c;
|
||||
--c4: #10a593; --c4-soft: #dbf4f0; --c4-ink: #08766a;
|
||||
--c5: #ffc23c; --c5-soft: #fff3d3; --c5-ink: #835c00;
|
||||
--c6: #3b82f6; --c6-soft: #e2ecfe; --c6-ink: #1e57c4;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(40, 20, 90, 0.05), 0 10px 30px -18px rgba(60, 30, 140, 0.28);
|
||||
--shadow-pop: 0 2px 6px rgba(40, 20, 90, 0.08), 0 24px 48px -20px rgba(60, 30, 140, 0.4);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Dashboard stat-tile tokens. Defined once here on :root so EVERY theme
|
||||
inherits them (theme blocks below don't redefine them), and they resolve
|
||||
through the per-theme color tokens — so by default the eight tiles all read
|
||||
as a plain raised surface, exactly like the rest of the app. The vibrant
|
||||
`carnival` theme below overrides each tile with its own color. The tile
|
||||
foreground vars let the colored variant flip to light text without touching
|
||||
the global text tokens. Dashboard.jsx references these as literal
|
||||
`bg-[var(--stat-N)]` classes (Tailwind only generates classes it can see as
|
||||
complete strings, so the per-tile names are spelled out, not built up). */
|
||||
:root {
|
||||
/* Text on accent-warm fills (the For Sale badge, "Check" flags). The warm
|
||||
oranges are too light for white text in the light themes, so this is the
|
||||
theme's dark ink everywhere — on Midnight that's the surface color. */
|
||||
--color-on-accent-warm: var(--color-text-primary);
|
||||
[data-theme='confetti-dark'] {
|
||||
--color-surface: #110e1d;
|
||||
--color-surface-raised: #1a1630;
|
||||
--color-surface-sunken: #231e3d;
|
||||
--color-border: #2e2850;
|
||||
--color-border-strong: #3d3666;
|
||||
--color-text-primary: #f3f0fb;
|
||||
--color-text-secondary: #b5adcc;
|
||||
--color-text-faint: #867e9f;
|
||||
--color-accent: #9a84ff;
|
||||
--color-accent-hover: #ae9cff;
|
||||
--color-on-accent: #140e2b;
|
||||
--color-accent-warm: #ffa36a;
|
||||
--color-on-accent-warm: #1c1530;
|
||||
--color-danger: #ff6f93;
|
||||
|
||||
--stat-fg: var(--color-text-primary);
|
||||
--stat-fg-muted: var(--color-text-secondary);
|
||||
--stat-1: var(--color-surface-raised);
|
||||
--stat-2: var(--color-surface-raised);
|
||||
--stat-3: var(--color-surface-raised);
|
||||
--stat-4: var(--color-surface-raised);
|
||||
--stat-5: var(--color-surface-raised);
|
||||
--stat-6: var(--color-surface-raised);
|
||||
--stat-7: var(--color-surface-raised);
|
||||
--stat-8: var(--color-surface-raised);
|
||||
--stat-1-hover: var(--color-surface-sunken);
|
||||
--stat-2-hover: var(--color-surface-sunken);
|
||||
--stat-3-hover: var(--color-surface-sunken);
|
||||
--stat-4-hover: var(--color-surface-sunken);
|
||||
--stat-5-hover: var(--color-surface-sunken);
|
||||
--stat-6-hover: var(--color-surface-sunken);
|
||||
--stat-7-hover: var(--color-surface-sunken);
|
||||
--stat-8-hover: var(--color-surface-sunken);
|
||||
--c1: #9a84ff; --c1-soft: #2b2453; --c1-ink: #bcaeff;
|
||||
--c2: #ff79a9; --c2-soft: #3b1d31; --c2-ink: #ff9dc0;
|
||||
--c3: #ffa36a; --c3-soft: #3b2619; --c3-ink: #ffbb8f;
|
||||
--c4: #3dd0bc; --c4-soft: #12332f; --c4-ink: #6fe2d2;
|
||||
--c5: #ffd66b; --c5-soft: #3a3016; --c5-ink: #ffe08f;
|
||||
--c6: #7faeff; --c6-soft: #1b2a4a; --c6-ink: #a6c6ff;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 30px -18px rgba(0, 0, 0, 0.8);
|
||||
--shadow-pop: 0 2px 6px rgba(0, 0, 0, 0.5), 0 24px 48px -16px rgba(0, 0, 0, 0.9);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Dark theme: accent-hover is *lighter* than accent (the opposite of the light
|
||||
themes), since a darker hover would disappear against dark surfaces. */
|
||||
[data-theme='midnight'] {
|
||||
--color-surface: #1a1d1a;
|
||||
--color-surface-raised: #242823;
|
||||
--color-surface-sunken: #151814;
|
||||
--color-border: #3a3f38;
|
||||
--color-text-primary: #ece8e0;
|
||||
--color-text-secondary: #a8a294;
|
||||
--color-accent: #7fa56e;
|
||||
--color-accent-hover: #94b884;
|
||||
--color-accent-warm: #d9954a;
|
||||
--color-danger: #d46a6a;
|
||||
--color-on-accent-warm: var(--color-surface);
|
||||
/* Meadow: the pastel sibling — sage primary, softer hues. */
|
||||
[data-theme='meadow-light'] {
|
||||
--color-surface: #f7f6ef;
|
||||
--color-surface-raised: #fffffe;
|
||||
--color-surface-sunken: #efede3;
|
||||
--color-border: #e5e1d3;
|
||||
--color-border-strong: #d4cfbd;
|
||||
--color-text-primary: #22241e;
|
||||
--color-text-secondary: #5c5f54;
|
||||
--color-text-faint: #8a8c80;
|
||||
--color-accent: #4d7a58;
|
||||
--color-accent-hover: #406a4a;
|
||||
--color-on-accent: #ffffff;
|
||||
--color-accent-warm: #f1b784;
|
||||
--color-on-accent-warm: #22241e;
|
||||
--color-danger: #b8404f;
|
||||
|
||||
--c1: #7fa889; --c1-soft: #e3efe5; --c1-ink: #3c6847;
|
||||
--c2: #e79bb0; --c2-soft: #fbe6ec; --c2-ink: #9e3e5c;
|
||||
--c3: #f1b784; --c3-soft: #fcebdb; --c3-ink: #8b5019;
|
||||
--c4: #a99bdc; --c4-soft: #ece8f8; --c4-ink: #5644a1;
|
||||
--c5: #efd474; --c5-soft: #faf2d2; --c5-ink: #73600f;
|
||||
--c6: #93badf; --c6-soft: #e4eef8; --c6-ink: #2c6091;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(40, 50, 30, 0.05), 0 10px 30px -18px rgba(40, 50, 30, 0.25);
|
||||
--shadow-pop: 0 2px 6px rgba(40, 50, 30, 0.08), 0 24px 48px -20px rgba(40, 50, 30, 0.35);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='ocean'] {
|
||||
--color-surface: #f0f5f8;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-surface-sunken: #e1eaf0;
|
||||
--color-border: #c2d3de;
|
||||
--color-text-primary: #14222b;
|
||||
--color-text-secondary: #516a79;
|
||||
--color-accent: #2e6e8e;
|
||||
--color-accent-hover: #245870;
|
||||
--color-accent-warm: #e0913b;
|
||||
--color-danger: #c0504d;
|
||||
[data-theme='meadow-dark'] {
|
||||
--color-surface: #141612;
|
||||
--color-surface-raised: #1c1f1a;
|
||||
--color-surface-sunken: #252922;
|
||||
--color-border: #30352c;
|
||||
--color-border-strong: #3f4539;
|
||||
--color-text-primary: #eef0e8;
|
||||
--color-text-secondary: #aeb2a5;
|
||||
--color-text-faint: #80847a;
|
||||
--color-accent: #9cc6a6;
|
||||
--color-accent-hover: #b1d4b8;
|
||||
--color-on-accent: #11160f;
|
||||
--color-accent-warm: #f5c69a;
|
||||
--color-on-accent-warm: #22241e;
|
||||
--color-danger: #f08a98;
|
||||
|
||||
--c1: #9cc6a6; --c1-soft: #1f2c22; --c1-ink: #b3d6bb;
|
||||
--c2: #f2afc1; --c2-soft: #36232a; --c2-ink: #f6c3d1;
|
||||
--c3: #f5c69a; --c3-soft: #352a1e; --c3-ink: #f8d3b0;
|
||||
--c4: #bdb1ea; --c4-soft: #272438; --c4-ink: #cfc6f0;
|
||||
--c5: #f2de8e; --c5-soft: #332e19; --c5-ink: #f6e7ad;
|
||||
--c6: #a9cbea; --c6-soft: #1e2a36; --c6-ink: #c1daf1;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 14px 30px -18px rgba(0, 0, 0, 0.8);
|
||||
--shadow-pop: 0 2px 6px rgba(0, 0, 0, 0.5), 0 24px 48px -16px rgba(0, 0, 0, 0.9);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='berry'] {
|
||||
--color-surface: #f8f2f6;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-surface-sunken: #efe2ea;
|
||||
--color-border: #dcc6d3;
|
||||
--color-text-primary: #2a1a26;
|
||||
--color-text-secondary: #7a6070;
|
||||
--color-accent: #9b3b6e;
|
||||
--color-accent-hover: #7e2d58;
|
||||
--color-accent-warm: #c9762e;
|
||||
--color-danger: #c0392b;
|
||||
}
|
||||
|
||||
[data-theme='sunset'] {
|
||||
--color-surface: #fbf3ec;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-surface-sunken: #f4e6da;
|
||||
--color-border: #e6cdb8;
|
||||
--color-text-primary: #2a1c14;
|
||||
--color-text-secondary: #7a604d;
|
||||
--color-accent: #b84a26;
|
||||
--color-accent-hover: #9a3d1c;
|
||||
--color-accent-warm: #e0913b;
|
||||
--color-danger: #b33a3a;
|
||||
}
|
||||
|
||||
/* Carnival: the deliberately loud theme. A clean light base lets the
|
||||
multi-colored dashboard tiles (overridden below) pop. The stat tiles flip to
|
||||
white text via --stat-fg. */
|
||||
[data-theme='carnival'] {
|
||||
--color-surface: #faf7fd;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-surface-sunken: #f0eaf7;
|
||||
--color-border: #e2d9ec;
|
||||
--color-text-primary: #1b1228;
|
||||
--color-text-secondary: #6a5c7c;
|
||||
--color-accent: #7c3aed;
|
||||
--color-accent-hover: #6d28d9;
|
||||
--color-accent-warm: #ea580c;
|
||||
--color-danger: #e11d48;
|
||||
|
||||
--stat-fg: #ffffff;
|
||||
--stat-fg-muted: rgba(255, 255, 255, 0.82);
|
||||
--stat-1: #7c3aed; /* herd — violet */
|
||||
--stat-2: #2563eb; /* bucks — blue */
|
||||
--stat-3: #db2777; /* does — pink */
|
||||
--stat-4: #ea580c; /* kits — orange */
|
||||
--stat-5: #059669; /* for sale — green*/
|
||||
--stat-6: #d97706; /* on hold — amber */
|
||||
--stat-7: #0891b2; /* sold — cyan */
|
||||
--stat-8: #475569; /* archived — slate*/
|
||||
--stat-1-hover: #6d28d9;
|
||||
--stat-2-hover: #1d4ed8;
|
||||
--stat-3-hover: #be185d;
|
||||
--stat-4-hover: #c2410c;
|
||||
--stat-5-hover: #047857;
|
||||
--stat-6-hover: #b45309;
|
||||
--stat-7-hover: #0e7490;
|
||||
--stat-8-hover: #334155;
|
||||
}
|
||||
|
||||
/* Theme-aware form controls. Inputs/textareas/selects across the app don't set
|
||||
their own background or text color, so without this they fall back to the
|
||||
browser default (white background, near-black text) — fine on the light
|
||||
themes (where it matches `surface-raised`) but unreadable on the dark
|
||||
Midnight theme, which renders a glaring white box with washed-out placeholder
|
||||
text. Tying the controls to the theme tokens keeps them legible everywhere
|
||||
without touching each component. */
|
||||
@layer base {
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Tabular figures everywhere: counts, weights, dates and ear numbers line
|
||||
up in lists and tiles without per-element classes. */
|
||||
html {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Theme-aware form controls. Inputs/textareas/selects across the app don't
|
||||
set their own background or text color, so without this they fall back to
|
||||
the browser default (white box, near-black text) — unreadable in the dark
|
||||
modes. Tying the controls to the theme tokens keeps them legible
|
||||
everywhere without touching each component. */
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='color']),
|
||||
textarea,
|
||||
select {
|
||||
|
|
@ -161,6 +161,11 @@
|
|||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
input[type='checkbox'],
|
||||
input[type='radio'] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* iOS Safari zooms the page when focusing a control whose font-size is under
|
||||
16px, which is jarring in the installed PWA. Most controls use text-sm, so
|
||||
bump them to 16px on small screens only; desktop keeps the denser size. */
|
||||
|
|
@ -174,11 +179,85 @@
|
|||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-faint);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Confetti scatter used behind the dashboard hero and empty states. */
|
||||
.confetti-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gentle lift for clickable cards (rabbit cards, litter cards). */
|
||||
.lift {
|
||||
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
|
||||
}
|
||||
.lift:hover {
|
||||
transform: translateY(-3px) rotate(-0.3deg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
/* Bracket pedigree (PedigreeTreeReadOnly, PedigreeTree): each .ped-br is a
|
||||
node (.ped-nw) beside its parents (.ped-pair). A vertical bar joins the
|
||||
two parents and short horizontal ticks connect each node to it. The bar
|
||||
runs 25%→75% of the pair's height, i.e. center to center of two
|
||||
equal-height children. */
|
||||
.ped-br {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ped-nw {
|
||||
position: relative;
|
||||
flex: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ped-pair {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 16px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.ped-pair::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
border-left: 2.5px solid var(--color-border-strong);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.ped-pair > .ped-br > .ped-nw::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
top: 50%;
|
||||
border-top: 2.5px solid var(--color-border-strong);
|
||||
}
|
||||
.ped-br > .ped-nw.has::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -16px;
|
||||
width: 16px;
|
||||
top: 50%;
|
||||
border-top: 2.5px solid var(--color-border-strong);
|
||||
}
|
||||
|
||||
/* Hide a horizontal scroller's bar (pill rows on phones) while keeping it
|
||||
scrollable. */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* `!important` is deliberate: several inputs/buttons already set
|
||||
`focus:outline-none` (paired with their own `focus:ring-2`) which would
|
||||
otherwise win on specificity over a plain `:focus-visible` rule and leave
|
||||
|
|
@ -199,4 +278,7 @@
|
|||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.lift:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
// Coat-color placeholder fills. A rabbit without a photo is drawn as a little
|
||||
// lop (components/CoatArt.jsx) tinted from its free-text `color_name`, so a
|
||||
// herd of photo-less records still reads at a glance. This is a deliberately
|
||||
// rough reading of ARBA color names — it only has to look right, not be a
|
||||
// genetics engine — and never blocks on unrecognized text (falls back to a
|
||||
// neutral coat).
|
||||
|
||||
const WHITE = '#f7f2ea'
|
||||
|
||||
// Order matters: the first keyword found wins, so the more specific names
|
||||
// (smoke pearl, siamese sable, chestnut agouti) come before the plain colors.
|
||||
const SOLIDS = [
|
||||
[/smoke/, '#b9b3ad'],
|
||||
[/siamese|sable/, '#6e5747'],
|
||||
[/seal/, '#3e3029'],
|
||||
[/chin(chilla)?\b|chin\//, '#9b9590'],
|
||||
[/squirrel/, '#a5a9b0'],
|
||||
[/lynx/, '#cdb8a3'],
|
||||
[/frost/, '#e3ddd6'],
|
||||
[/opal/, '#9aa0a8'],
|
||||
[/chestnut|agouti/, '#8a5a3a'],
|
||||
[/lilac/, '#a99a9e'],
|
||||
[/choc|cholocate/, '#6b4a36'],
|
||||
[/blue/, '#76808f'],
|
||||
[/black|blk|bkn black/, '#2e2723'],
|
||||
[/orange|red/, '#e39a55'],
|
||||
[/fawn/, '#d9b37a'],
|
||||
[/cream/, '#ead7b5'],
|
||||
]
|
||||
|
||||
// Tort shading follows the self color it's a tort *of*.
|
||||
const TORT_SHADE = [
|
||||
[/blue/, '#6a6670'],
|
||||
[/choc/, '#5e4030'],
|
||||
[/lilac/, '#8d7f80'],
|
||||
]
|
||||
const TORT_BASE = '#b5743f'
|
||||
const TORT_DARK = '#5a3a28'
|
||||
|
||||
const NEUTRAL = { base: '#cfc6bc', patch: '#b9afa4', ear: '#a89d91' }
|
||||
|
||||
function solidFor(s) {
|
||||
for (const [re, hex] of SOLIDS) if (re.test(s)) return hex
|
||||
return null
|
||||
}
|
||||
|
||||
export function coatColors(colorName) {
|
||||
const s = (colorName ?? '').toLowerCase().trim()
|
||||
if (!s || s === 'n/a') return NEUTRAL
|
||||
|
||||
const broken = /broken|bkn|\bbr\b/.test(s)
|
||||
const isWhite = /\b(rew|bew)\b|eyed? white|pointed white/.test(s)
|
||||
|
||||
if (isWhite) {
|
||||
const pointed = /point/.test(s)
|
||||
return { base: WHITE, patch: WHITE, ear: pointed ? '#4e4743' : '#e9dfd2' }
|
||||
}
|
||||
|
||||
if (/tort/.test(s)) {
|
||||
const shade = TORT_SHADE.find(([re]) => re.test(s))?.[1] ?? TORT_DARK
|
||||
return broken
|
||||
? { base: WHITE, patch: TORT_BASE, ear: shade }
|
||||
: { base: TORT_BASE, patch: '#c9895a', ear: shade }
|
||||
}
|
||||
|
||||
if (/harl|tri/.test(s)) {
|
||||
const dark = solidFor(s.replace(/orange|fawn|cream/g, '')) ?? '#2e2723'
|
||||
return { base: '#e39a55', patch: dark, ear: dark }
|
||||
}
|
||||
|
||||
if (/magpie/.test(s)) {
|
||||
const dark = solidFor(s) ?? '#2e2723'
|
||||
return { base: WHITE, patch: dark, ear: dark }
|
||||
}
|
||||
|
||||
if (/point|himalayan/.test(s) && !/sable point/.test(s)) {
|
||||
const dark = solidFor(s) ?? '#2e2723'
|
||||
return { base: WHITE, patch: WHITE, ear: dark }
|
||||
}
|
||||
|
||||
if (/sable point|siamese/.test(s)) {
|
||||
return broken
|
||||
? { base: WHITE, patch: '#8b7058', ear: '#4e3d33' }
|
||||
: { base: '#cdb597', patch: '#8b7058', ear: '#4e3d33' }
|
||||
}
|
||||
|
||||
const solid = solidFor(s) ?? NEUTRAL.base
|
||||
if (broken) return { base: WHITE, patch: solid, ear: solid }
|
||||
// Otters carry a cream belly/eye ring — shown as a light muzzle patch.
|
||||
if (/otter/.test(s)) return { base: solid, patch: '#e8d6b8', ear: solid }
|
||||
return { base: solid, patch: solid, ear: solid }
|
||||
}
|
||||
|
||||
// Literal class strings (Tailwind only generates classes it can see whole).
|
||||
// The soft tint behind a placeholder is picked from the rabbit's id so it's
|
||||
// stable across renders and varied across a grid.
|
||||
export const TINT_BG = ['bg-c1-soft', 'bg-c2-soft', 'bg-c3-soft', 'bg-c4-soft', 'bg-c5-soft', 'bg-c6-soft']
|
||||
|
||||
export function tintFor(id) {
|
||||
const n = Number(id)
|
||||
return TINT_BG[(Number.isFinite(n) ? Math.abs(n) : String(id ?? '').length) % TINT_BG.length]
|
||||
}
|
||||
|
|
@ -72,3 +72,29 @@ export function addDays(isoDate, days) {
|
|||
// Rabbit gestation is ~31 days; kits wean at ~8 weeks.
|
||||
export const GESTATION_DAYS = 31
|
||||
export const WEAN_DAYS = 56
|
||||
|
||||
// Whole days from today (local midnight) to an ISO date — negative once past.
|
||||
export function daysUntil(isoDate) {
|
||||
if (!isoDate) return null
|
||||
const target = new Date(`${isoDate}T00:00:00`)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
return Math.round((target - today) / 86400000)
|
||||
}
|
||||
|
||||
export function todayIso() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Breeding milestones counted from the breed date. Palpation is commonly done
|
||||
// 10–14 days after breeding; the nest box goes in a few days before kindling.
|
||||
export const PALPATE_DAYS = 12
|
||||
export const NEST_BOX_DAYS = 28
|
||||
|
||||
// Free-text color names include placeholders like "N/A" from old imports;
|
||||
// treat those as no color for display.
|
||||
export function displayColor(colorName) {
|
||||
const c = (colorName ?? '').trim()
|
||||
return c && !/^n\/?a$/i.test(c) ? c : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,95 @@
|
|||
// Theme registry. The actual palettes live as CSS variables in src/index.css
|
||||
// (one `[data-theme='…']` block per theme); this module is the single source
|
||||
// of truth for the *list* of themes, their labels, the small preview swatches
|
||||
// the Settings picker renders, and the browser chrome `theme-color`.
|
||||
// (one `[data-theme='<palette>-<light|dark>']` block each); this module owns
|
||||
// the list of palettes and modes, how a stored choice resolves to one of those
|
||||
// blocks, and the browser chrome `theme-color`.
|
||||
//
|
||||
// Persistence: the chosen theme is stored in the backend `settings` table
|
||||
// (key `theme`) so it syncs across devices. localStorage mirrors it only as a
|
||||
// fast-path cache so the correct theme can be applied before the settings
|
||||
// request resolves (avoiding a flash of the default theme on load).
|
||||
// A stored theme id is `<palette>-<mode>`, e.g. `confetti-system`. The
|
||||
// `system` mode follows the OS light/dark preference live.
|
||||
//
|
||||
// Persistence: the chosen id is stored per user in the backend (settings key
|
||||
// `theme`) so it syncs across devices. localStorage mirrors it only as a
|
||||
// fast-path cache so the inline script in index.html can apply it before the
|
||||
// settings request resolves (avoiding a flash of the default theme).
|
||||
|
||||
export const DEFAULT_THEME = 'classic'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'mykindlebox-theme'
|
||||
|
||||
// `themeColor` drives the <meta name="theme-color"> (PWA / mobile browser
|
||||
// chrome). `swatch` is just for the Settings preview tiles — the live colors
|
||||
// always come from the CSS variables, these are only an at-a-glance preview.
|
||||
export const THEMES = [
|
||||
export const PALETTES = [
|
||||
{
|
||||
id: 'classic',
|
||||
label: 'Classic Cream',
|
||||
description: 'The original warm, earthy look.',
|
||||
themeColor: '#4a6741',
|
||||
swatch: { surface: '#f7f3ec', accent: '#4a6741', warm: '#c47d2e' },
|
||||
id: 'confetti',
|
||||
label: 'Confetti',
|
||||
description: 'Party colors on calm surfaces.',
|
||||
swatch: ['#6a48ee', '#ee4e8b', '#ff8a3d', '#10a593', '#ffc23c', '#3b82f6'],
|
||||
themeColor: { light: '#faf8ff', dark: '#110e1d' },
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
label: 'Midnight',
|
||||
description: 'Easy-on-the-eyes dark mode.',
|
||||
themeColor: '#1a1d1a',
|
||||
swatch: { surface: '#242823', accent: '#7fa56e', warm: '#d9954a' },
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
label: 'Ocean',
|
||||
description: 'Cool blues and teal.',
|
||||
themeColor: '#2e6e8e',
|
||||
swatch: { surface: '#f0f5f8', accent: '#2e6e8e', warm: '#e0913b' },
|
||||
},
|
||||
{
|
||||
id: 'berry',
|
||||
label: 'Berry',
|
||||
description: 'Bold plum and magenta.',
|
||||
themeColor: '#9b3b6e',
|
||||
swatch: { surface: '#f8f2f6', accent: '#9b3b6e', warm: '#c9762e' },
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
label: 'Sunset',
|
||||
description: 'Warm coral and amber.',
|
||||
themeColor: '#b84a26',
|
||||
swatch: { surface: '#fbf3ec', accent: '#b84a26', warm: '#e0913b' },
|
||||
},
|
||||
{
|
||||
id: 'carnival',
|
||||
label: 'Carnival',
|
||||
description: 'Bright multi-color dashboard.',
|
||||
themeColor: '#7c3aed',
|
||||
swatch: { surface: '#7c3aed', accent: '#db2777', warm: '#ea580c' },
|
||||
id: 'meadow',
|
||||
label: 'Meadow',
|
||||
description: 'Soft pastels with a sage accent.',
|
||||
swatch: ['#7fa889', '#e79bb0', '#f1b784', '#a99bdc', '#efd474', '#93badf'],
|
||||
themeColor: { light: '#f7f6ef', dark: '#141612' },
|
||||
},
|
||||
]
|
||||
|
||||
const THEME_IDS = new Set(THEMES.map((t) => t.id))
|
||||
export const MODES = [
|
||||
{ value: 'system', label: 'Auto' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]
|
||||
|
||||
export function isValidTheme(id) {
|
||||
return typeof id === 'string' && THEME_IDS.has(id)
|
||||
export const DEFAULT_THEME = 'confetti-system'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'mykindlebox-theme'
|
||||
|
||||
// Themes from before the Confetti redesign. Midnight was the only dark one;
|
||||
// everything else maps to the default (which follows the OS).
|
||||
const LEGACY_THEMES = { midnight: 'confetti-dark' }
|
||||
|
||||
const PALETTE_IDS = new Set(PALETTES.map((p) => p.id))
|
||||
const MODE_IDS = new Set(MODES.map((m) => m.value))
|
||||
|
||||
export function parseTheme(id) {
|
||||
const [palette, mode] = String(LEGACY_THEMES[id] ?? id ?? '').split('-')
|
||||
if (PALETTE_IDS.has(palette) && MODE_IDS.has(mode)) return { palette, mode }
|
||||
return parseTheme(DEFAULT_THEME)
|
||||
}
|
||||
|
||||
export function composeTheme(palette, mode) {
|
||||
return `${palette}-${mode}`
|
||||
}
|
||||
|
||||
// Falls back to the default for unknown/missing ids so a stray DB value or an
|
||||
// old cached id can never leave the app unstyled.
|
||||
export function normalizeTheme(id) {
|
||||
return isValidTheme(id) ? id : DEFAULT_THEME
|
||||
const { palette, mode } = parseTheme(id)
|
||||
return composeTheme(palette, mode)
|
||||
}
|
||||
|
||||
// Applies a theme to the document: sets the data-theme attribute (which swaps
|
||||
// the CSS variables), updates the browser-chrome theme-color meta, and mirrors
|
||||
// the choice to localStorage for next load's flash-free start.
|
||||
const darkQuery = typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia('(prefers-color-scheme: dark)')
|
||||
: null
|
||||
|
||||
let current = DEFAULT_THEME
|
||||
|
||||
function paint() {
|
||||
const { palette, mode } = parseTheme(current)
|
||||
const resolved = mode === 'system' ? (darkQuery?.matches ? 'dark' : 'light') : mode
|
||||
document.documentElement.dataset.theme = composeTheme(palette, resolved)
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
const entry = PALETTES.find((p) => p.id === palette)
|
||||
if (meta && entry) meta.setAttribute('content', entry.themeColor[resolved])
|
||||
}
|
||||
|
||||
// An OS light/dark flip repaints only while the choice is `system`.
|
||||
darkQuery?.addEventListener?.('change', () => {
|
||||
if (parseTheme(current).mode === 'system') paint()
|
||||
})
|
||||
|
||||
// Applies a theme choice to the document (swapping the CSS variables and the
|
||||
// browser-chrome color) and mirrors it to localStorage for next load's
|
||||
// flash-free start.
|
||||
export function applyTheme(id) {
|
||||
const theme = normalizeTheme(id)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.dataset.theme = theme
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
const entry = THEMES.find((t) => t.id === theme)
|
||||
if (meta && entry) meta.setAttribute('content', entry.themeColor)
|
||||
}
|
||||
current = normalizeTheme(id)
|
||||
if (typeof document !== 'undefined') paint()
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, current)
|
||||
} catch {
|
||||
// localStorage can throw in private mode / when disabled — non-fatal,
|
||||
// the backend setting remains the source of truth.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
|
@ -10,9 +10,13 @@ import StickyActionBar from '../components/StickyActionBar'
|
|||
import PedigreeScan from '../components/PedigreeScan'
|
||||
import Button from '../components/Button'
|
||||
import { Field, Input } from '../components/FormControls'
|
||||
import Icon from '../components/Icon'
|
||||
import { CoatAvatar } from '../components/CoatArt'
|
||||
import {
|
||||
getSettings,
|
||||
getRabbit,
|
||||
getRabbits,
|
||||
getLitters,
|
||||
createRabbit,
|
||||
uploadRabbitPhoto,
|
||||
getPedigreeScanStatus,
|
||||
|
|
@ -100,26 +104,24 @@ function Stepper({ step, canReach, onSelect }) {
|
|||
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"
|
||||
className={`flex min-h-[44px] min-w-0 items-center gap-2 rounded-full py-1 pl-1 pr-4 text-left transition-colors disabled:cursor-default ${
|
||||
current ? 'bg-surface-raised shadow-card' : 'hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<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'
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-display text-sm font-extrabold ${
|
||||
current ? 'bg-accent text-on-accent' : done ? 'bg-c4-soft text-c4-ink' : 'bg-surface-sunken text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
{done ? <Icon name="check" className="h-4 w-4" strokeWidth={3} /> : n}
|
||||
</span>
|
||||
<span
|
||||
className={`truncate font-body text-sm ${current ? 'font-medium text-text-primary' : 'text-text-secondary'}`}
|
||||
className={`truncate font-body text-sm font-bold ${current ? 'text-text-primary' : 'text-text-secondary'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
{n < STEP_LABELS.length && <span className="h-px flex-1 bg-border" aria-hidden="true" />}
|
||||
{n < STEP_LABELS.length && <span className="h-0.5 flex-1 rounded-full bg-border" aria-hidden="true" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
@ -127,6 +129,91 @@ function Stepper({ step, canReach, onSelect }) {
|
|||
)
|
||||
}
|
||||
|
||||
// Up to this many chips per parent.
|
||||
const SUGGESTION_LIMIT = 5
|
||||
|
||||
// Does and bucks you're likely to pick: parents of your most recent litters
|
||||
// first (newest first), topped up with the rest of the active herd. Purely a
|
||||
// shortcut — the tree below still takes anyone.
|
||||
function suggestParents(litters, active, sex) {
|
||||
const key = sex === 'buck' ? 'sire' : 'dam'
|
||||
const byId = new Map(active.map((r) => [r.id, r]))
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const litter of litters) {
|
||||
const parent = litter[key]
|
||||
if (parent?.id == null || seen.has(parent.id)) continue
|
||||
seen.add(parent.id)
|
||||
out.push({ ...parent, ...byId.get(parent.id), recent: true })
|
||||
}
|
||||
for (const r of active) {
|
||||
if (r.sex !== sex || seen.has(r.id)) continue
|
||||
seen.add(r.id)
|
||||
out.push(r)
|
||||
}
|
||||
return out.slice(0, SUGGESTION_LIMIT)
|
||||
}
|
||||
|
||||
function SuggestedParents({ chosen, onPick }) {
|
||||
const { data: litters } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||
const { data: active } = useQuery({
|
||||
queryKey: ['rabbits', { status: 'active' }],
|
||||
queryFn: () => getRabbits({ status: 'active' }),
|
||||
})
|
||||
if (!litters || !active) return null
|
||||
|
||||
const rows = [
|
||||
{ slot: 'dam', title: 'Dam', dot: 'bg-c2', ring: 'ring-c2', list: suggestParents(litters, active, 'doe') },
|
||||
{ slot: 'sire', title: 'Sire', dot: 'bg-c6', ring: 'ring-c6', list: suggestParents(litters, active, 'buck') },
|
||||
].filter((row) => row.list.length > 0)
|
||||
if (rows.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-border bg-surface-raised p-4 shadow-card md:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="sparkle" className="h-5 w-5 text-c1" />
|
||||
<h2 className="font-display text-lg font-bold text-text-primary">Quick pick</h2>
|
||||
<span className="font-body text-sm text-text-secondary">— recent parents first</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.slot} className="flex flex-col gap-2 md:flex-row md:items-center md:gap-3">
|
||||
<span className="flex w-14 shrink-0 items-center gap-1.5 font-body text-sm font-bold text-text-secondary">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${row.dot}`} aria-hidden="true" />
|
||||
{row.title}
|
||||
</span>
|
||||
<div className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 pb-1 md:mx-0 md:flex-wrap md:px-0 md:pb-0">
|
||||
{row.list.map((rabbit) => {
|
||||
const selected = chosen[row.slot] === rabbit.id
|
||||
return (
|
||||
<button
|
||||
key={rabbit.id}
|
||||
type="button"
|
||||
onClick={() => onPick(row.slot, rabbit)}
|
||||
aria-pressed={selected}
|
||||
className={`flex min-h-[48px] shrink-0 items-center gap-2 rounded-full border-[1.5px] py-1 pl-1 pr-3.5 transition-colors ${
|
||||
selected ? `border-transparent bg-surface-sunken ring-2 ${row.ring}` : 'border-border hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<CoatAvatar rabbit={rabbit} className="h-10 w-10" />
|
||||
<span className="text-left">
|
||||
<span className="block font-body text-sm font-bold text-text-primary">{rabbit.name}</span>
|
||||
{rabbit.recent && (
|
||||
<span className="block font-body text-[11px] font-semibold text-text-faint">Bred recently</span>
|
||||
)}
|
||||
</span>
|
||||
{selected && <Icon name="check" className="h-4 w-4 text-text-primary" strokeWidth={3} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRabbit() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
|
@ -156,6 +243,7 @@ function AddRabbit() {
|
|||
const [pedigreeSeed, setPedigreeSeed] = useState(null)
|
||||
const [pedigreeKey, setPedigreeKey] = useState(0)
|
||||
const [confirm, confirmDialog] = useConfirm()
|
||||
const treeRef = useRef(null)
|
||||
|
||||
const step2Valid = details.name.trim().length > 0
|
||||
|
||||
|
|
@ -237,7 +325,7 @@ function AddRabbit() {
|
|||
// loading before PedigreeTree mounts, since it only reads `initialState`
|
||||
// once, at mount (same constraint EditRabbit.jsx works around).
|
||||
if ((sireId && sireLoading) || (damId && damLoading)) {
|
||||
return <PageHeader title="Add Rabbit" subtitle="Loading…" />
|
||||
return <PageHeader title="Add a rabbit" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
const pedigreeInitialState = pedigreeSeed
|
||||
|
|
@ -330,7 +418,7 @@ function AddRabbit() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Add Rabbit" />
|
||||
<PageHeader title="Add a rabbit" subtitle="Start from the family tree — details come next." />
|
||||
|
||||
<Stepper step={step} canReach={(n) => n < 3 || step2Valid} onSelect={goToStep} />
|
||||
|
||||
|
|
@ -348,7 +436,7 @@ function AddRabbit() {
|
|||
{/* 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 className={step === 1 ? 'flex flex-col gap-5' : 'hidden'}>
|
||||
<Field label="Rabbit name">
|
||||
<Input
|
||||
type="text"
|
||||
|
|
@ -358,11 +446,16 @@ function AddRabbit() {
|
|||
className="w-full max-w-md"
|
||||
/>
|
||||
</Field>
|
||||
<SuggestedParents
|
||||
chosen={{ sire: pedigreeFields.sire_id, dam: pedigreeFields.dam_id }}
|
||||
onPick={(slot, rabbit) => treeRef.current?.setParent(slot, rabbit)}
|
||||
/>
|
||||
<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.
|
||||
Tap any ancestor to search your herd, type them in, or mark them unknown. Picking a rabbit from your herd
|
||||
fills in its ancestors automatically.
|
||||
</p>
|
||||
<PedigreeTree
|
||||
ref={treeRef}
|
||||
key={pedigreeKey}
|
||||
uncertainSlots={scanResult ? uncertainScanSlots(scanResult) : undefined}
|
||||
subjectName={details.name.trim() || 'New Rabbit'}
|
||||
|
|
@ -419,11 +512,13 @@ function AddRabbit() {
|
|||
{step === 1 && (
|
||||
<Button type="button" onClick={() => goToStep(2)}>
|
||||
Next: Details
|
||||
<Icon name="chevronRight" className="h-[18px] w-[18px]" strokeWidth={2.4} />
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button type="button" onClick={handleNextFromDetails}>
|
||||
Next: Review
|
||||
<Icon name="chevronRight" className="h-[18px] w-[18px]" strokeWidth={2.4} />
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
|
|
|
|||
|
|
@ -1,144 +1,98 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getStats, getLitters, getRabbits, getSettings } from '../api'
|
||||
import { getStats, getLitters, getRabbits } from '../api'
|
||||
import { useAuth } from '../authContext'
|
||||
import Card from '../components/Card'
|
||||
import Button from '../components/Button'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import ErrorPanel from '../components/ErrorPanel'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import { GESTATION_DAYS, addDays, formatDate } from '../lib/format'
|
||||
import Icon from '../components/Icon'
|
||||
import { CoatAvatar } from '../components/CoatArt'
|
||||
import { KitDots } from '../components/LitterCard'
|
||||
import {
|
||||
GESTATION_DAYS,
|
||||
NEST_BOX_DAYS,
|
||||
PALPATE_DAYS,
|
||||
WEAN_DAYS,
|
||||
addDays,
|
||||
daysUntil,
|
||||
displayColor,
|
||||
formatDate,
|
||||
} from '../lib/format'
|
||||
|
||||
const BIRTHDAY_WINDOW_DAYS = 30
|
||||
const COMING_UP_WINDOW_DAYS = 45
|
||||
|
||||
// `tile` carries literal Tailwind classes (not built up dynamically — Tailwind
|
||||
// only generates classes it can see as complete strings) that bind each tile to
|
||||
// a --stat-N / --stat-N-hover CSS variable. Those variables default to a plain
|
||||
// raised surface in every theme (see index.css :root) and only turn colorful in
|
||||
// the `carnival` theme, so the dashboard re-themes with zero conditional logic.
|
||||
const STAT_CARDS = [
|
||||
{ key: 'herd', label: 'Herd', href: '/herd', tile: 'bg-[var(--stat-1)] hover:bg-[var(--stat-1-hover)]' },
|
||||
{ key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', tile: 'bg-[var(--stat-2)] hover:bg-[var(--stat-2-hover)]' },
|
||||
{ key: 'does', label: 'Does', href: '/herd?sex=doe', tile: 'bg-[var(--stat-3)] hover:bg-[var(--stat-3-hover)]' },
|
||||
{ key: 'kits', label: 'Kits', href: '/herd?sex=kits', tile: 'bg-[var(--stat-4)] hover:bg-[var(--stat-4-hover)]' },
|
||||
// The rest are status counts — shown as a smaller second row so the herd
|
||||
// makeup reads first.
|
||||
{ key: 'for_sale', label: 'For Sale', href: '/herd?status=for_sale', tile: 'bg-[var(--stat-5)] hover:bg-[var(--stat-5-hover)]' },
|
||||
{ key: 'on_hold', label: 'On Hold', href: '/herd?status=on_hold', tile: 'bg-[var(--stat-6)] hover:bg-[var(--stat-6-hover)]' },
|
||||
{ key: 'sold', label: 'Sold', href: '/herd?status=sold', tile: 'bg-[var(--stat-7)] hover:bg-[var(--stat-7-hover)]' },
|
||||
{ key: 'archived', label: 'Archived', href: '/herd?status=archived', tile: 'bg-[var(--stat-8)] hover:bg-[var(--stat-8-hover)]' },
|
||||
// The four headline tiles, each in its section's hue. Literal class strings
|
||||
// (Tailwind only generates classes it can see whole).
|
||||
const PRIMARY_TILES = [
|
||||
{ key: 'herd', label: 'Active herd', href: '/herd', icon: 'herd', tile: 'bg-c1-soft text-c1-ink' },
|
||||
{ key: 'for_sale', label: 'For sale', href: '/herd?status=for_sale', icon: 'tag', tile: 'bg-c4-soft text-c4-ink' },
|
||||
{ key: 'bucks', label: 'Bucks', href: '/herd?sex=buck', icon: 'bunny', tile: 'bg-c6-soft text-c6-ink' },
|
||||
{ key: 'does', label: 'Does', href: '/herd?sex=doe', icon: 'bunny', tile: 'bg-c2-soft text-c2-ink' },
|
||||
]
|
||||
|
||||
function StatsBar() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['stats'], queryFn: getStats })
|
||||
// The rest are smaller status counts, so the herd makeup reads first.
|
||||
const SECONDARY_TILES = [
|
||||
{ key: 'kits', label: 'Kits', href: '/herd?sex=kits' },
|
||||
{ key: 'on_hold', label: 'On hold', href: '/herd?status=on_hold' },
|
||||
{ key: 'sold', label: 'Sold', href: '/herd?status=sold' },
|
||||
{ key: 'archived', label: 'Archived', href: '/herd?status=archived' },
|
||||
]
|
||||
|
||||
if (isError) {
|
||||
return <ErrorPanel title="Couldn’t load herd counts" error={error} onRetry={refetch} />
|
||||
}
|
||||
|
||||
const primary = STAT_CARDS.slice(0, 4)
|
||||
const secondary = STAT_CARDS.slice(4)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{primary.map((card) =>
|
||||
isLoading ? (
|
||||
<Card key={card.key} className="h-20 animate-pulse bg-surface-sunken" />
|
||||
) : (
|
||||
<Link key={card.key} to={card.href}>
|
||||
{/* Deliberately NOT <Card>: Card hardcodes bg-surface-raised, which
|
||||
lands *after* the arbitrary-value bg-[var(--stat-N)] in the
|
||||
compiled CSS and overrides it — the carnival tile colors then
|
||||
only showed on hover. The tile's own var already defaults to
|
||||
surface-raised, so plain themes look identical. */}
|
||||
<div
|
||||
className={`rounded-lg border border-border shadow-sm flex flex-col gap-1 p-4 transition-colors ${card.tile}`}
|
||||
>
|
||||
<span className="font-body text-xs font-medium uppercase tracking-wide text-[var(--stat-fg-muted)]">
|
||||
{card.label}
|
||||
</span>
|
||||
<span className="font-display text-2xl text-[var(--stat-fg)]">{data[card.key]}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{secondary.map((card) =>
|
||||
isLoading ? (
|
||||
<Card key={card.key} className="h-11 animate-pulse bg-surface-sunken" />
|
||||
) : (
|
||||
<Link key={card.key} to={card.href}>
|
||||
<div
|
||||
className={`flex min-h-[44px] items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 shadow-sm transition-colors ${card.tile}`}
|
||||
>
|
||||
<span className="font-body text-sm text-[var(--stat-fg-muted)]">{card.label}</span>
|
||||
<span className="font-display text-lg text-[var(--stat-fg)]">{data[card.key]}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
function greeting() {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'Good morning'
|
||||
if (h < 18) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
function QuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||||
<Button to="/breedings/add" variant="secondary">Record Breeding</Button>
|
||||
<Button to="/litters/add" variant="secondary">Record Litter</Button>
|
||||
</div>
|
||||
)
|
||||
function relativeDay(days) {
|
||||
if (days === 0) return 'Today'
|
||||
if (days === 1) return 'Tomorrow'
|
||||
if (days < 0) return `${-days} days ago`
|
||||
return `In ${days} days`
|
||||
}
|
||||
|
||||
function RecentLitters() {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||
// --- Derived schedule -------------------------------------------------------
|
||||
// Everything on the dashboard's "Coming up" list is computed from data the app
|
||||
// already has (litter breed/kindle dates and rabbit DOBs) — nothing is stored.
|
||||
// Palpate/nest box/due follow from a pending breeding's breed date; weaning
|
||||
// from a kindle date; birthdays from active rabbits' DOBs.
|
||||
|
||||
if (isLoading || isError) return null
|
||||
const EVENT_KINDS = {
|
||||
palpate: { icon: 'hand', pill: 'bg-c6-soft text-c6-ink' },
|
||||
nest: { icon: 'nest', pill: 'bg-c5-soft text-c5-ink' },
|
||||
due: { icon: 'heart', pill: 'bg-c2-soft text-c2-ink' },
|
||||
wean: { icon: 'litters', pill: 'bg-c3-soft text-c3-ink' },
|
||||
birthday: { icon: 'cake', pill: 'bg-c1-soft text-c1-ink' },
|
||||
}
|
||||
|
||||
// The API already returns newest first (by kindle date, else breeding date).
|
||||
const recent = data.slice(0, 3)
|
||||
function litterLabel(litter) {
|
||||
return `${litter.sire?.name ?? 'Unknown'} × ${litter.dam?.name ?? 'Unknown'}`
|
||||
}
|
||||
|
||||
if (recent.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="No litters yet"
|
||||
description="Record a breeding or litter to see it here."
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
function breedingEvents(litters) {
|
||||
const events = []
|
||||
for (const litter of litters) {
|
||||
const dam = litter.dam?.name ?? 'the doe'
|
||||
if (!litter.kindle_date && litter.breed_date) {
|
||||
events.push(
|
||||
{ kind: 'palpate', date: addDays(litter.breed_date, PALPATE_DAYS), title: `Palpate ${dam}`, detail: litterLabel(litter), to: `/litters/${litter.id}` },
|
||||
{ kind: 'nest', date: addDays(litter.breed_date, NEST_BOX_DAYS), title: `Nest box for ${dam}`, detail: litterLabel(litter), to: `/litters/${litter.id}` },
|
||||
{ kind: 'due', date: addDays(litter.breed_date, GESTATION_DAYS), title: `${dam} is due`, detail: litterLabel(litter), to: `/litters/${litter.id}` }
|
||||
)
|
||||
} else if (litter.kindle_date) {
|
||||
events.push({
|
||||
kind: 'wean',
|
||||
date: addDays(litter.kindle_date, WEAN_DAYS),
|
||||
title: `Wean ${dam}'s litter`,
|
||||
detail: `${litter.kit_count ?? 0} kits · ${litterLabel(litter)}`,
|
||||
to: `/litters/${litter.id}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="divide-y divide-border">
|
||||
{recent.map((litter) => (
|
||||
<Link
|
||||
key={litter.id}
|
||||
to={`/litters/${litter.id}`}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-surface-sunken"
|
||||
>
|
||||
<div>
|
||||
<p className="font-body text-sm font-medium text-text-primary">
|
||||
{litter.sire?.name ?? 'Unknown'} × {litter.dam?.name ?? 'Unknown'}
|
||||
</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{litter.kindle_date
|
||||
? formatDate(litter.kindle_date)
|
||||
: `Due ${formatDate(addDays(litter.breed_date, GESTATION_DAYS))}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-body text-sm text-text-secondary">
|
||||
{litter.kindle_date
|
||||
? `${litter.kit_count ?? 0} ${litter.kit_count === 1 ? 'kit' : 'kits'}`
|
||||
: 'Expecting'}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
return events
|
||||
}
|
||||
|
||||
function getUpcomingBirthdays(rabbits, windowDays = BIRTHDAY_WINDOW_DAYS) {
|
||||
|
|
@ -151,84 +105,394 @@ function getUpcomingBirthdays(rabbits, windowDays = BIRTHDAY_WINDOW_DAYS) {
|
|||
const dob = new Date(`${rabbit.dob}T00:00:00`)
|
||||
let next = new Date(startOfToday.getFullYear(), dob.getMonth(), dob.getDate())
|
||||
if (next < startOfToday) next = new Date(startOfToday.getFullYear() + 1, dob.getMonth(), dob.getDate())
|
||||
const daysUntil = Math.round((next - startOfToday) / 86400000)
|
||||
const days = Math.round((next - startOfToday) / 86400000)
|
||||
const turningAge = next.getFullYear() - dob.getFullYear()
|
||||
return { rabbit, nextDate: next, daysUntil, turningAge }
|
||||
return { rabbit, nextDate: next, daysUntil: days, turningAge }
|
||||
})
|
||||
.filter((entry) => entry.daysUntil <= windowDays)
|
||||
.filter((entry) => entry.daysUntil <= windowDays && entry.turningAge > 0)
|
||||
.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||
}
|
||||
|
||||
function formatMonthDay(date) {
|
||||
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
|
||||
function toIso(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function UpcomingBirthdays() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
function useSchedule() {
|
||||
const litters = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||
const active = useQuery({
|
||||
queryKey: ['rabbits', { status: 'active' }],
|
||||
queryFn: () => getRabbits({ status: 'active' }),
|
||||
})
|
||||
|
||||
if (isLoading || isError) return null
|
||||
const birthdays = active.data ? getUpcomingBirthdays(active.data) : []
|
||||
const events = [
|
||||
...(litters.data ? breedingEvents(litters.data) : []),
|
||||
...birthdays.map(({ rabbit, nextDate, turningAge }) => ({
|
||||
kind: 'birthday',
|
||||
date: toIso(nextDate),
|
||||
title: `${rabbit.name} turns ${turningAge}`,
|
||||
detail: [displayColor(rabbit.color_name), rabbit.breed].filter(Boolean).join(' · ') || 'Birthday',
|
||||
to: `/herd/${rabbit.id}`,
|
||||
rabbit,
|
||||
})),
|
||||
]
|
||||
.map((event) => ({ ...event, days: daysUntil(event.date) }))
|
||||
.filter((event) => event.days >= 0 && event.days <= COMING_UP_WINDOW_DAYS)
|
||||
.sort((a, b) => a.days - b.days || a.title.localeCompare(b.title))
|
||||
|
||||
const upcoming = getUpcomingBirthdays(data)
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="No upcoming birthdays"
|
||||
description={`No active rabbits have a birthday in the next ${BIRTHDAY_WINDOW_DAYS} days.`}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
return {
|
||||
events,
|
||||
litters: litters.data ?? [],
|
||||
isLoading: litters.isLoading || active.isLoading,
|
||||
isError: litters.isError,
|
||||
refetch: litters.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hero -------------------------------------------------------------------
|
||||
|
||||
function ConfettiScatter() {
|
||||
return (
|
||||
<svg className="confetti-bg h-full w-full" viewBox="0 0 800 220" preserveAspectRatio="xMaxYMid meet" aria-hidden="true">
|
||||
<rect x="560" y="26" width="18" height="8" rx="4" fill="var(--c5)" transform="rotate(25 569 30)" />
|
||||
<circle cx="640" cy="60" r="7" fill="var(--c4)" />
|
||||
<rect x="700" y="30" width="18" height="8" rx="4" fill="var(--c2)" transform="rotate(-30 709 34)" />
|
||||
<circle cx="740" cy="120" r="5" fill="var(--c3)" />
|
||||
<rect x="610" y="150" width="18" height="8" rx="4" fill="var(--c6)" transform="rotate(40 619 154)" />
|
||||
<circle cx="520" cy="180" r="6" fill="var(--c5)" />
|
||||
<rect x="680" y="186" width="16" height="7" rx="3.5" fill="var(--c4)" transform="rotate(-12 688 189)" />
|
||||
<circle cx="770" cy="200" r="4" fill="var(--c2)" />
|
||||
<circle cx="470" cy="40" r="4" fill="var(--c3)" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Hero({ events, pendingCount, stats }) {
|
||||
const { user } = useAuth()
|
||||
const today = events.filter((e) => e.days === 0)
|
||||
const birthday = today.find((e) => e.kind === 'birthday')
|
||||
|
||||
const dateLine = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
|
||||
let headline
|
||||
if (birthday) headline = `${birthday.title}!`
|
||||
else if (today.length > 0) headline = today.length === 1 ? today[0].title : `${today.length} things on today`
|
||||
else headline = `${greeting()}${user?.username ? `, ${user.username}` : ''}`
|
||||
|
||||
const summary = [
|
||||
stats ? `${stats.herd} in the active herd` : null,
|
||||
pendingCount > 0 ? `${pendingCount} ${pendingCount === 1 ? 'doe' : 'does'} expecting` : null,
|
||||
today.length > (birthday ? 1 : 0) && birthday ? `plus ${today.length - 1} more today` : null,
|
||||
!today.length && events[0] ? `next up: ${events[0].title.toLowerCase()} ${relativeDay(events[0].days).toLowerCase()}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<Card className="divide-y divide-border">
|
||||
{upcoming.map(({ rabbit, nextDate, daysUntil, turningAge }) => (
|
||||
<section className="relative flex flex-wrap items-center justify-between gap-5 overflow-hidden rounded-[28px] bg-accent px-6 py-7 text-on-accent md:px-8">
|
||||
<ConfettiScatter />
|
||||
<div className="relative min-w-0">
|
||||
<p className="font-body text-sm font-bold opacity-85">{dateLine}</p>
|
||||
<h1 className="mt-1 font-display text-3xl font-bold leading-[1.05] tracking-tight md:text-[40px]">{headline}</h1>
|
||||
{summary && <p className="mt-2 font-body text-[15px] opacity-90">{summary}</p>}
|
||||
</div>
|
||||
<div className="relative flex flex-wrap gap-2.5">
|
||||
<Link
|
||||
key={rabbit.id}
|
||||
to={`/herd/${rabbit.id}`}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-surface-sunken"
|
||||
to="/herd/add"
|
||||
className="inline-flex min-h-[46px] items-center gap-2 rounded-full bg-surface-raised px-5 font-body text-[15px] font-bold text-text-primary transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
<div>
|
||||
<p className="font-body text-sm font-medium text-text-primary">{rabbit.name}</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatMonthDay(nextDate)} · turning {turningAge}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-body text-sm text-text-secondary">
|
||||
{daysUntil === 0 ? 'Today' : daysUntil === 1 ? 'Tomorrow' : `in ${daysUntil} days`}
|
||||
</span>
|
||||
<Icon name="add" className="h-[18px] w-[18px]" strokeWidth={2.4} />
|
||||
Add rabbit
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
to="/breedings/add"
|
||||
className="inline-flex min-h-[46px] items-center gap-2 rounded-full border-[1.5px] border-current px-5 font-body text-[15px] font-bold transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
<Icon name="heart" className="h-[18px] w-[18px]" />
|
||||
Record breeding
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tiles --------------------------------------------------------------------
|
||||
|
||||
function StatTiles({ stats, isLoading }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-3.5">
|
||||
{PRIMARY_TILES.map((t) =>
|
||||
isLoading ? (
|
||||
<div key={t.key} className="h-[118px] animate-pulse rounded-[22px] bg-surface-sunken" />
|
||||
) : (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.href}
|
||||
className={`group relative flex min-h-[118px] flex-col overflow-hidden rounded-[22px] p-4 transition-transform hover:-translate-y-0.5 md:px-5 ${t.tile}`}
|
||||
>
|
||||
<span className="font-body text-sm font-bold">{t.label}</span>
|
||||
<span className="mt-auto font-display text-[42px] font-extrabold leading-none tracking-tight md:text-[46px]">
|
||||
{stats?.[t.key] ?? 0}
|
||||
</span>
|
||||
<Icon
|
||||
name={t.icon}
|
||||
className="absolute -right-2 -top-2 h-16 w-16 opacity-20 transition-transform group-hover:rotate-6"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{SECONDARY_TILES.map((t) => (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.href}
|
||||
className="flex min-h-[48px] items-center justify-between gap-2 rounded-2xl border border-border bg-surface-raised px-4 py-2 transition-colors hover:bg-surface-sunken"
|
||||
>
|
||||
<span className="font-body text-sm font-semibold text-text-secondary">{t.label}</span>
|
||||
<span className="font-display text-xl font-extrabold text-text-primary">
|
||||
{isLoading ? '–' : (stats?.[t.key] ?? 0)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Coming up ------------------------------------------------------------------
|
||||
|
||||
function DatePill({ date, className }) {
|
||||
const d = new Date(`${date}T00:00:00`)
|
||||
return (
|
||||
<span className={`flex w-14 shrink-0 flex-col items-center rounded-2xl py-1.5 leading-tight ${className}`}>
|
||||
<span className="font-body text-[11px] font-extrabold uppercase tracking-wider">
|
||||
{d.toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
<span className="font-display text-[22px] font-extrabold">{d.getDate()}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ComingUp({ events, isLoading }) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-3 pt-5">
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Coming up</h2>
|
||||
<span className="font-body text-xs font-bold text-text-faint">Next {COMING_UP_WINDOW_DAYS} days</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2 px-5 pb-5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-14 animate-pulse rounded-2xl bg-surface-sunken" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
title="All quiet"
|
||||
description="Record a breeding and its palpate, nest-box and due dates show up here — along with birthdays and weaning."
|
||||
action={
|
||||
<Button to="/breedings/add" variant="secondary" size="sm">
|
||||
Record breeding
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{events.slice(0, 8).map((event) => {
|
||||
const kind = EVENT_KINDS[event.kind]
|
||||
return (
|
||||
<li key={`${event.kind}-${event.to}-${event.date}`} className="border-t border-border">
|
||||
<Link to={event.to} className="flex items-center gap-3.5 px-5 py-3 transition-colors hover:bg-surface-sunken">
|
||||
<DatePill date={event.date} className={kind.pill} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-body text-[15px] font-bold text-text-primary">{event.title}</span>
|
||||
<span className="block truncate font-body text-[13px] text-text-secondary">{event.detail}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 font-body text-xs font-bold ${
|
||||
event.days === 0 ? kind.pill : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{relativeDay(event.days)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
// --- Expecting (gestation tracker) ----------------------------------------------
|
||||
|
||||
function GestationBar({ litter }) {
|
||||
const day = Math.max(0, -daysUntil(litter.breed_date))
|
||||
const pct = Math.min(100, (day / GESTATION_DAYS) * 100)
|
||||
const markers = [
|
||||
{ at: PALPATE_DAYS, label: 'Palpate' },
|
||||
{ at: NEST_BOX_DAYS, label: 'Nest box' },
|
||||
]
|
||||
const dueIn = daysUntil(addDays(litter.breed_date, GESTATION_DAYS))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<PageHeader title={settings?.rabbitry_name || 'Dashboard'} subtitle="Herd overview" />
|
||||
<section>
|
||||
<StatsBar />
|
||||
</section>
|
||||
<section>
|
||||
<QuickActions />
|
||||
</section>
|
||||
<section className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-lg text-text-primary">Recent Litters</h2>
|
||||
<RecentLitters />
|
||||
<Link to={`/litters/${litter.id}`} className="block rounded-2xl p-3 transition-colors hover:bg-surface-sunken">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate font-body text-[15px] font-bold text-text-primary">{litterLabel(litter)}</span>
|
||||
<span className="shrink-0 font-body text-xs font-bold text-c2-ink">
|
||||
{dueIn > 0 ? `Due in ${dueIn}d` : dueIn === 0 ? 'Due today' : `${-dueIn}d overdue`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-2.5 h-3 rounded-full bg-surface-sunken" role="img" aria-label={`Day ${day} of ${GESTATION_DAYS}`}>
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-c1 to-c2" style={{ width: `${pct}%` }} />
|
||||
{markers.map((m) => (
|
||||
<span
|
||||
key={m.label}
|
||||
className={`absolute top-1/2 h-4 w-1 -translate-y-1/2 rounded-full ${day >= m.at ? 'bg-surface-raised' : 'bg-border-strong'}`}
|
||||
style={{ left: `${(m.at / GESTATION_DAYS) * 100}%` }}
|
||||
title={`${m.label} · day ${m.at}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between font-body text-[11px] font-semibold text-text-faint">
|
||||
<span>Bred {formatDate(litter.breed_date)?.replace(/, \d{4}$/, '')}</span>
|
||||
<span>Day {Math.min(day, GESTATION_DAYS)} of {GESTATION_DAYS}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Expecting({ litters }) {
|
||||
const pending = litters.filter((l) => !l.kindle_date && l.breed_date)
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-2 pt-5">
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Expecting</h2>
|
||||
<Link to="/breedings/add" className="font-body text-sm font-bold text-accent hover:underline">
|
||||
+ Breeding
|
||||
</Link>
|
||||
</div>
|
||||
{pending.length === 0 ? (
|
||||
<p className="px-5 pb-5 font-body text-sm text-text-secondary">
|
||||
No does are bred right now. When you record a breeding, its gestation progress shows here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 px-2 pb-3">
|
||||
{pending.map((litter) => (
|
||||
<GestationBar key={litter.id} litter={litter} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-lg text-text-primary">Upcoming Birthdays</h2>
|
||||
<UpcomingBirthdays />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Recent litters & for sale ---------------------------------------------------
|
||||
|
||||
function RecentLitters({ litters }) {
|
||||
const recent = litters.filter((l) => l.kindle_date).slice(0, 4)
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-3 pt-5">
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Recent litters</h2>
|
||||
<Link to="/litters" className="font-body text-sm font-bold text-accent hover:underline">
|
||||
All litters
|
||||
</Link>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<p className="px-5 pb-5 font-body text-sm text-text-secondary">No litters kindled yet.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{recent.map((litter) => (
|
||||
<li key={litter.id} className="border-t border-border">
|
||||
<Link to={`/litters/${litter.id}`} className="flex items-center gap-4 px-5 py-3.5 transition-colors hover:bg-surface-sunken">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-body text-[15px] font-bold text-text-primary">{litterLabel(litter)}</span>
|
||||
<span className="block font-body text-[13px] text-text-secondary">Kindled {formatDate(litter.kindle_date)}</span>
|
||||
</span>
|
||||
<KitDots count={litter.kit_count} className="hidden max-w-[140px] sm:flex" />
|
||||
<span className="shrink-0 rounded-full bg-c3-soft px-2.5 py-1 font-body text-xs font-bold text-c3-ink">
|
||||
{litter.kit_count ?? 0} {litter.kit_count === 1 ? 'kit' : 'kits'}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ForSale() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['rabbits', { status: 'for_sale' }],
|
||||
queryFn: () => getRabbits({ status: 'for_sale' }),
|
||||
})
|
||||
if (isLoading || !data?.length) return null
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">
|
||||
For sale <span className="text-text-faint">· {data.length}</span>
|
||||
</h2>
|
||||
<Link to="/herd?status=for_sale" className="font-body text-sm font-bold text-accent hover:underline">
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-2 md:mx-0 md:px-0">
|
||||
{data.slice(0, 10).map((rabbit) => (
|
||||
<Link
|
||||
key={rabbit.id}
|
||||
to={`/herd/${rabbit.id}`}
|
||||
className="lift flex w-[220px] shrink-0 items-center gap-3 rounded-[22px] border border-border bg-surface-raised p-2.5 pr-4 shadow-card"
|
||||
>
|
||||
<CoatAvatar rabbit={rabbit} className="h-12 w-12" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-body text-[15px] font-bold text-text-primary">{rabbit.name}</span>
|
||||
<span className="block truncate font-body text-[13px] text-text-secondary">
|
||||
{displayColor(rabbit.color_name) || (rabbit.sex === 'buck' ? 'Buck' : rabbit.sex === 'doe' ? 'Doe' : '—')}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const stats = useQuery({ queryKey: ['stats'], queryFn: getStats })
|
||||
const schedule = useSchedule()
|
||||
const pendingCount = schedule.litters.filter((l) => !l.kindle_date && l.breed_date).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Hero events={schedule.events} pendingCount={pendingCount} stats={stats.data} />
|
||||
|
||||
{stats.isError ? (
|
||||
<ErrorPanel title="Couldn’t load herd counts" error={stats.error} onRetry={stats.refetch} />
|
||||
) : (
|
||||
<StatTiles stats={stats.data} isLoading={stats.isLoading} />
|
||||
)}
|
||||
|
||||
{schedule.isError ? (
|
||||
<ErrorPanel title="Couldn’t load litters" onRetry={schedule.refetch} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.6fr_1fr]">
|
||||
<ComingUp events={schedule.events} isLoading={schedule.isLoading} />
|
||||
<div className="flex flex-col gap-6">
|
||||
<Expecting litters={schedule.litters} />
|
||||
<RecentLitters litters={schedule.litters} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ForSale />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ function EditRabbit() {
|
|||
/>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-4">
|
||||
<h2 className="font-display text-lg text-text-primary">Parents</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Parents</h2>
|
||||
<p className="mt-1 font-body text-xs text-text-secondary">
|
||||
Grandparents and beyond come from the parents’ own records — to change one, edit the sire or dam.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { Link, useSearchParams } from 'react-router-dom'
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRabbits } from '../api'
|
||||
import Card from '../components/Card'
|
||||
import Badge from '../components/Badge'
|
||||
import Badge, { SexBadge } from '../components/Badge'
|
||||
import { CoatAvatar } from '../components/CoatArt'
|
||||
import Icon from '../components/Icon'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import Button from '../components/Button'
|
||||
import RabbitCard from '../components/RabbitCard'
|
||||
import { Select } from '../components/FormControls'
|
||||
import ErrorPanel from '../components/ErrorPanel'
|
||||
import { SEX_LABELS, formatAge, formatWeight } from '../lib/format'
|
||||
import { displayColor, formatAge, formatWeight } from '../lib/format'
|
||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||
|
||||
const SEX_TABS = [
|
||||
|
|
@ -46,34 +48,14 @@ const STATUS_OPTIONS = [
|
|||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="9" cy="9" r="6" />
|
||||
<path d="M17.5 17.5 13.5 13.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GridIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1" />
|
||||
<rect x="11" y="2" width="7" height="7" rx="1" />
|
||||
<rect x="2" y="11" width="7" height="7" rx="1" />
|
||||
<rect x="11" y="11" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ListIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor">
|
||||
<rect x="2" y="3" width="16" height="3" rx="1" />
|
||||
<rect x="2" y="8.5" width="16" height="3" rx="1" />
|
||||
<rect x="2" y="14" width="16" height="3" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
// Subtitle wording for the current status filter ("14 rabbits in the active herd").
|
||||
const HEADER_SCOPE = {
|
||||
active: 'in the active herd',
|
||||
for_sale: 'for sale',
|
||||
on_hold: 'on hold',
|
||||
sold: 'sold',
|
||||
archived: 'archived',
|
||||
all: 'on record',
|
||||
}
|
||||
|
||||
function SearchBox({ q, onSearchChange }) {
|
||||
|
|
@ -88,15 +70,15 @@ function SearchBox({ q, onSearchChange }) {
|
|||
}, [searchInput])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[44px] md:min-h-[32px] min-w-0 flex-1 items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 md:flex-none">
|
||||
<SearchIcon />
|
||||
<div className="flex min-h-[46px] min-w-0 flex-1 items-center gap-2.5 rounded-full border-[1.5px] border-border bg-surface-raised px-4 text-text-faint transition-colors focus-within:border-accent md:min-h-[42px] md:flex-none">
|
||||
<Icon name="search" className="h-[18px] w-[18px] shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Name, ear #, color"
|
||||
aria-label="Search herd by name, ear number or color"
|
||||
className="w-full min-w-0 bg-transparent md:w-48 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
className="w-full min-w-0 !bg-transparent py-2 font-body text-sm font-medium text-text-primary focus:outline-none md:w-52"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -110,17 +92,18 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
|||
const changedCount = (status !== DEFAULT_STATUS ? 1 : 0) + (sort !== 'name' ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-1 rounded-md border border-border bg-surface-raised p-1">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 md:mx-0 md:px-0">
|
||||
{SEX_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => onSexChange(tab.value)}
|
||||
className={`min-h-[44px] md:min-h-[32px] rounded-md px-3 py-1.5 font-body text-sm transition-colors ${
|
||||
aria-pressed={sex === tab.value}
|
||||
className={`min-h-[44px] shrink-0 rounded-full border-[1.5px] px-4 font-body text-sm font-bold transition-colors md:min-h-[42px] ${
|
||||
sex === tab.value
|
||||
? 'bg-accent text-surface'
|
||||
: 'text-text-primary hover:bg-surface-sunken'
|
||||
? 'border-text-primary bg-text-primary text-surface'
|
||||
: 'border-border bg-surface-raised text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
|
|
@ -141,7 +124,7 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
|||
>
|
||||
Filters
|
||||
{changedCount > 0 && (
|
||||
<span className="rounded-full bg-accent px-1.5 text-xs text-surface">{changedCount}</span>
|
||||
<span className="rounded-full bg-accent px-1.5 text-xs text-on-accent">{changedCount}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -169,27 +152,24 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
|||
))}
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-1 rounded-md border border-border bg-surface-raised p-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Grid view"
|
||||
onClick={() => onViewChange('grid')}
|
||||
className={`flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 transition-colors md:min-h-[32px] md:min-w-[32px] ${
|
||||
view === 'grid' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<GridIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="List view"
|
||||
onClick={() => onViewChange('list')}
|
||||
className={`flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 transition-colors md:min-h-[32px] md:min-w-[32px] ${
|
||||
view === 'list' ? 'bg-accent text-surface' : 'text-text-secondary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<ListIcon />
|
||||
</button>
|
||||
<div className="flex gap-1 rounded-full border-[1.5px] border-border bg-surface-sunken p-1">
|
||||
{[
|
||||
{ value: 'grid', label: 'Card view', icon: 'grid' },
|
||||
{ value: 'list', label: 'List view', icon: 'list' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
aria-label={opt.label}
|
||||
aria-pressed={view === opt.value}
|
||||
onClick={() => onViewChange(opt.value)}
|
||||
className={`flex min-h-[38px] min-w-[44px] items-center justify-center rounded-full transition-colors md:min-h-[32px] ${
|
||||
view === opt.value ? 'bg-surface-raised text-text-primary shadow-card' : 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={opt.icon} className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -199,13 +179,13 @@ function FilterBar({ sex, status, sort, q, view, onSexChange, onStatusChange, on
|
|||
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] md:gap-[18px]">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<div className="aspect-square animate-pulse bg-surface-sunken" />
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-surface-sunken" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-surface-sunken" />
|
||||
<Card key={i} className="p-2.5">
|
||||
<div className="aspect-[5/4] animate-pulse rounded-[18px] bg-surface-sunken" />
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded-full bg-surface-sunken" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded-full bg-surface-sunken" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -218,7 +198,7 @@ function ListSkeleton() {
|
|||
<Card className="divide-y divide-border overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-surface-sunken" />
|
||||
<div className="h-8 w-full animate-pulse rounded-full bg-surface-sunken" />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
|
@ -227,7 +207,7 @@ function ListSkeleton() {
|
|||
|
||||
function RabbitGrid({ rabbits }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] md:gap-[18px]">
|
||||
{rabbits.map((rabbit) => (
|
||||
<RabbitCard key={rabbit.id} rabbit={rabbit} />
|
||||
))}
|
||||
|
|
@ -241,34 +221,30 @@ function RabbitListView({ rabbits }) {
|
|||
<Card className="overflow-x-auto">
|
||||
<table className="w-full font-body text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-text-secondary">
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Sex</th>
|
||||
<th className="px-4 py-2 font-medium">Color</th>
|
||||
<th className="px-4 py-2 font-medium">Age</th>
|
||||
<th className="px-4 py-2 font-medium">Weight</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<tr className="text-left text-xs text-text-secondary">
|
||||
<th className="px-5 pb-2 pt-4 font-bold">Rabbit</th>
|
||||
<th className="px-3 pb-2 pt-4 font-bold">Ear #</th>
|
||||
<th className="px-3 pb-2 pt-4 font-bold">Color</th>
|
||||
<th className="px-3 pb-2 pt-4 font-bold">Age</th>
|
||||
<th className="px-3 pb-2 pt-4 font-bold">Weight</th>
|
||||
<th className="px-5 pb-2 pt-4 font-bold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rabbits.map((rabbit, i) => (
|
||||
<tr
|
||||
key={rabbit.id}
|
||||
className={i % 2 === 1 ? 'bg-surface-sunken' : ''}
|
||||
>
|
||||
{rabbits.map((rabbit) => (
|
||||
<tr key={rabbit.id} className="border-t border-border transition-colors hover:bg-surface-sunken">
|
||||
<td className="p-0">
|
||||
<Link
|
||||
to={`/herd/${rabbit.id}`}
|
||||
className="block px-4 py-3 font-medium text-text-primary hover:underline"
|
||||
>
|
||||
{rabbit.name}
|
||||
<Link to={`/herd/${rabbit.id}`} className="flex min-w-[200px] items-center gap-3 px-5 py-2.5 font-bold text-text-primary">
|
||||
<CoatAvatar rabbit={rabbit} className="h-10 w-10" />
|
||||
<span className="truncate">{rabbit.name}</span>
|
||||
<SexBadge sex={rabbit.sex} short />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{SEX_LABELS[rabbit.sex] ?? rabbit.sex}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{rabbit.color_name ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatAge(rabbit.dob)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatWeight(rabbit.weight_lbs, weightUnit) ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="whitespace-nowrap px-3 py-2.5 font-mono text-xs font-semibold text-text-primary">{rabbit.ear_number ?? '—'}</td>
|
||||
<td className="px-3 py-2.5 text-text-secondary">{displayColor(rabbit.color_name) ?? '—'}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-text-secondary">{rabbit.dob ? formatAge(rabbit.dob) : '—'}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2.5 text-text-secondary">{formatWeight(rabbit.weight_lbs, weightUnit) ?? '—'}</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<Badge status={rabbit.status} />
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -326,9 +302,17 @@ function Herd() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Herd"
|
||||
title="Your herd"
|
||||
subtitle={
|
||||
isLoading || isError
|
||||
? null
|
||||
: `${rabbits.length} ${rabbits.length === 1 ? 'rabbit' : 'rabbits'} ${HEADER_SCOPE[status] ?? ''}`.trim()
|
||||
}
|
||||
action={
|
||||
<Button to="/herd/add" variant="primary">Add Rabbit</Button>
|
||||
<Button to="/herd/add" variant="primary">
|
||||
<Icon name="add" className="h-[18px] w-[18px]" strokeWidth={2.4} />
|
||||
Add rabbit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,23 +3,79 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import Card from '../components/Card'
|
||||
import Badge from '../components/Badge'
|
||||
import Badge, { SexBadge } from '../components/Badge'
|
||||
import Icon from '../components/Icon'
|
||||
import { CoatAvatar } from '../components/CoatArt'
|
||||
import { KitDots } from '../components/LitterCard'
|
||||
import Button from '../components/Button'
|
||||
import LoadError from '../components/LoadError'
|
||||
import { getLitter, deleteLitter } from '../api'
|
||||
import { GESTATION_DAYS, SEX_LABELS, WEAN_DAYS, addDays, formatDate, formatWeight } from '../lib/format'
|
||||
import {
|
||||
GESTATION_DAYS,
|
||||
NEST_BOX_DAYS,
|
||||
PALPATE_DAYS,
|
||||
WEAN_DAYS,
|
||||
addDays,
|
||||
daysUntil,
|
||||
displayColor,
|
||||
formatDate,
|
||||
formatWeight,
|
||||
} from '../lib/format'
|
||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
function ParentLink({ parent }) {
|
||||
if (!parent) return <p className="font-body text-sm text-text-secondary">Unknown</p>
|
||||
function ParentTile({ label, parent, tone }) {
|
||||
const body = (
|
||||
<>
|
||||
<p className="font-body text-xs font-bold opacity-80">{label}</p>
|
||||
<p className="mt-0.5 truncate font-body text-[15px] font-extrabold">{parent?.name ?? 'Unknown'}</p>
|
||||
</>
|
||||
)
|
||||
if (!parent) return <div className="min-w-0 rounded-2xl bg-surface-sunken px-4 py-3 text-text-secondary">{body}</div>
|
||||
return (
|
||||
<Link to={`/herd/${parent.id}`} className="font-body text-sm text-text-primary hover:text-accent">
|
||||
{parent.name}
|
||||
<Link to={`/herd/${parent.id}`} className={`min-w-0 rounded-2xl px-4 py-3 transition-transform hover:-translate-y-0.5 ${tone}`}>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Bred → palpate → nest box → due (or kindled → wean) as a row of milestones,
|
||||
// each marked done / today / upcoming from today's date.
|
||||
function Milestones({ items }) {
|
||||
return (
|
||||
<ol className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const days = daysUntil(item.date)
|
||||
const done = days < 0
|
||||
const today = days === 0
|
||||
return (
|
||||
<li
|
||||
key={item.label}
|
||||
className={`flex items-center gap-3 rounded-2xl border-[1.5px] px-3 py-2.5 ${
|
||||
today ? 'border-transparent bg-c5-soft text-c5-ink' : done ? 'border-border text-text-secondary' : 'border-border text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
|
||||
done ? 'bg-c4-soft text-c4-ink' : today ? 'bg-c5 text-[#1c1530]' : 'bg-surface-sunken text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={done ? 'check' : item.icon} className="h-[18px] w-[18px]" strokeWidth={done ? 3 : 1.9} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-body text-sm font-bold">{item.label}</span>
|
||||
<span className="block truncate font-body text-xs opacity-80">
|
||||
{formatDate(item.date)?.replace(/, \d{4}$/, '')}
|
||||
{today ? ' · today' : !done ? ` · ${days}d` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function LitterDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -45,11 +101,10 @@ function LitterDetail() {
|
|||
const kits = litter.kits ?? []
|
||||
|
||||
// A litter that hasn't kindled yet shows projections off the breeding date:
|
||||
// ~31-day gestation to the expected kindle, then weaning ~56 days after kindle.
|
||||
// ~31-day gestation to the expected kindle (milestones below add the rest).
|
||||
const kindled = Boolean(litter.kindle_date)
|
||||
const expectedKindle =
|
||||
!kindled && litter.breed_date ? formatDate(addDays(litter.breed_date, GESTATION_DAYS)) : null
|
||||
const weanDate = kindled ? formatDate(addDays(litter.kindle_date, WEAN_DAYS)) : null
|
||||
|
||||
async function handleDelete() {
|
||||
const confirmed = await confirm({
|
||||
|
|
@ -78,122 +133,134 @@ function LitterDetail() {
|
|||
if (litter.dam?.id != null) addKitParams.set('dam', litter.dam.id)
|
||||
if (kindled) addKitParams.set('dob', litter.kindle_date)
|
||||
|
||||
const milestones = kindled
|
||||
? [
|
||||
...(litter.breed_date ? [{ label: 'Bred', date: litter.breed_date, icon: 'heart' }] : []),
|
||||
{ label: 'Kindled', date: litter.kindle_date, icon: 'litters' },
|
||||
{ label: 'Wean', date: addDays(litter.kindle_date, WEAN_DAYS), icon: 'calendar' },
|
||||
]
|
||||
: litter.breed_date
|
||||
? [
|
||||
{ label: 'Bred', date: litter.breed_date, icon: 'heart' },
|
||||
{ label: 'Palpate', date: addDays(litter.breed_date, PALPATE_DAYS), icon: 'hand' },
|
||||
{ label: 'Nest box', date: addDays(litter.breed_date, NEST_BOX_DAYS), icon: 'nest' },
|
||||
{ label: 'Due', date: addDays(litter.breed_date, GESTATION_DAYS), icon: 'litters' },
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link
|
||||
to="/litters"
|
||||
className="inline-flex min-h-[40px] items-center gap-1 self-start rounded-full pr-3 font-body text-sm font-bold text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<Icon name="chevronLeft" className="h-[18px] w-[18px]" />
|
||||
Litters
|
||||
</Link>
|
||||
<PageHeader
|
||||
title={`${sireName} × ${damName}`}
|
||||
subtitle={
|
||||
kindled
|
||||
? formatDate(litter.kindle_date)
|
||||
: litter.breed_date
|
||||
? `Bred ${formatDate(litter.breed_date)}`
|
||||
? `Kindled ${formatDate(litter.kindle_date)}`
|
||||
: expectedKindle
|
||||
? `Expecting · due ${expectedKindle}`
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kindled ? (
|
||||
<Button to={`/herd/add?${addKitParams.toString()}`} size="sm">
|
||||
Add Kit
|
||||
<Icon name="add" className="h-4 w-4" strokeWidth={2.4} />
|
||||
Add kit
|
||||
</Button>
|
||||
) : (
|
||||
<Button to={`/litters/${litter.id}/edit?kindle=1`} size="sm">
|
||||
Record Kindle
|
||||
<Icon name="litters" className="h-4 w-4" />
|
||||
Record kindle
|
||||
</Button>
|
||||
)}
|
||||
<Button to={`/litters/${litter.id}/edit`} variant="secondary" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleDelete} disabled={deleting} className="text-danger">
|
||||
<Button variant="secondary" size="sm" onClick={handleDelete} disabled={deleting} className="!text-danger">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
<Card className="grid grid-cols-2 gap-4 p-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">Sire</p>
|
||||
<ParentLink parent={litter.sire} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">Dam</p>
|
||||
<ParentLink parent={litter.dam} />
|
||||
</div>
|
||||
{litter.breed_date && (
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">Breeding Date</p>
|
||||
<p className="font-body text-sm text-text-primary">{formatDate(litter.breed_date)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">
|
||||
{kindled ? 'Kindle Date' : 'Expected Kindle Date'}
|
||||
</p>
|
||||
<p className="font-body text-sm text-text-primary">
|
||||
{kindled ? formatDate(litter.kindle_date) : (expectedKindle ?? '—')}
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
|
||||
<Card className="flex flex-col gap-4 p-4 md:p-5">
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
|
||||
<ParentTile label="Sire" parent={litter.sire} tone="bg-c6-soft text-c6-ink" />
|
||||
<ParentTile label="Dam" parent={litter.dam} tone="bg-c2-soft text-c2-ink" />
|
||||
{kindled && (
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">Expected Wean Date</p>
|
||||
<p className="font-body text-sm text-text-primary">{weanDate}</p>
|
||||
<div className="col-span-2 flex min-w-0 items-center justify-between gap-3 rounded-2xl bg-c3-soft px-4 py-3 text-c3-ink sm:col-span-1">
|
||||
<span>
|
||||
<span className="block font-body text-xs font-bold opacity-80">Kits</span>
|
||||
<span className="block font-display text-2xl font-extrabold leading-tight">{litter.kit_count ?? kits.length}</span>
|
||||
</span>
|
||||
<KitDots count={litter.kit_count ?? kits.length} className="justify-end" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
{milestones.length > 0 && <Milestones items={milestones} />}
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-x-auto">
|
||||
<table className="w-full whitespace-nowrap text-left">
|
||||
<thead className="bg-surface-sunken">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Sex</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Color</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Ear Number</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Weight</th>
|
||||
{(kindled || kits.length > 0) && (
|
||||
<Card className="overflow-x-auto">
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-1 pt-5">
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Kits</h2>
|
||||
</div>
|
||||
<table className="w-full whitespace-nowrap text-left">
|
||||
<thead>
|
||||
<tr className="font-body text-xs text-text-secondary">
|
||||
<th className="px-5 pb-2 pt-3 font-bold">Kit</th>
|
||||
<th className="px-3 pb-2 pt-3 font-bold">Color</th>
|
||||
<th className="px-3 pb-2 pt-3 font-bold">Ear #</th>
|
||||
<th className="px-3 pb-2 pt-3 font-bold">Weight</th>
|
||||
<th className="px-5 pb-2 pt-3 font-bold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kits.map((kit) => (
|
||||
<tr key={kit.id} className="border-t border-border transition-colors hover:bg-surface-sunken">
|
||||
<td className="p-0">
|
||||
<Link to={`/herd/${kit.id}`} className="flex items-center gap-3 px-5 py-2.5 font-body text-sm font-bold text-text-primary">
|
||||
<CoatAvatar rabbit={kit} className="h-9 w-9" />
|
||||
{kit.name}
|
||||
<SexBadge sex={kit.sex} short />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-body text-sm text-text-secondary">{displayColor(kit.color_name) ?? '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs font-semibold text-text-primary">{kit.ear_number ?? '—'}</td>
|
||||
<td className="px-3 py-2.5 font-body text-sm text-text-secondary">
|
||||
{formatWeight(kit.weight_lbs, weightUnit) ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<Badge status={kit.status} />
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kits.map((kit) => (
|
||||
<tr key={kit.id} className="border-t border-border">
|
||||
<td className="px-4 py-2 font-body text-sm">
|
||||
<Link to={`/herd/${kit.id}`} className="text-text-primary hover:text-accent">
|
||||
{kit.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-body text-sm text-text-secondary">
|
||||
{SEX_LABELS[kit.sex] ?? kit.sex}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-body text-sm text-text-secondary">{kit.color_name ?? '—'}</td>
|
||||
<td className="px-4 py-2 font-body text-sm text-text-secondary">{kit.ear_number ?? '—'}</td>
|
||||
<td className="px-4 py-2 font-body text-sm">
|
||||
<Badge status={kit.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2 font-body text-sm text-text-secondary">
|
||||
{formatWeight(kit.weight_lbs, weightUnit) ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{kits.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center font-body text-sm text-text-secondary">
|
||||
No kits recorded yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
))}
|
||||
{kits.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="border-t border-border px-5 py-8 text-center font-body text-sm text-text-secondary">
|
||||
No kits added yet — use Add kit to give each one a record.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{litter.notes && (
|
||||
<Card className="p-4">
|
||||
<p className="font-body text-xs text-text-secondary">Notes</p>
|
||||
<p className="mt-1 font-body text-sm text-text-primary">{litter.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{litter.notes && (
|
||||
<Card className="p-5">
|
||||
<p className="font-body text-xs font-bold text-text-secondary">Notes</p>
|
||||
<p className="mt-1 whitespace-pre-line font-body text-sm text-text-primary">{litter.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
{confirmDialog}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ function LitterSection({ title, litters }) {
|
|||
if (litters.length === 0) return null
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-display text-lg text-text-primary">
|
||||
{title} <span className="font-body text-sm text-text-secondary">({litters.length})</span>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">
|
||||
{title} <span className="text-text-faint">· {litters.length}</span>
|
||||
</h2>
|
||||
{litters.map((litter) => (
|
||||
<LitterCard key={litter.id} litter={litter} />
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{litters.map((litter) => (
|
||||
<LitterCard key={litter.id} litter={litter} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,9 +39,9 @@ function Litters() {
|
|||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button to="/breedings/add" variant="secondary">
|
||||
Record Breeding
|
||||
Record breeding
|
||||
</Button>
|
||||
<Button to="/litters/add">Record Litter</Button>
|
||||
<Button to="/litters/add">Record litter</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@ import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import Card from '../components/Card'
|
||||
import Badge from '../components/Badge'
|
||||
import Badge, { SexBadge } from '../components/Badge'
|
||||
import Button from '../components/Button'
|
||||
import LoadError from '../components/LoadError'
|
||||
import ActionMenu from '../components/ActionMenu'
|
||||
import Silhouette from '../components/Silhouette'
|
||||
import Icon from '../components/Icon'
|
||||
import { CoatPanel } from '../components/CoatArt'
|
||||
import { KitDots } from '../components/LitterCard'
|
||||
import PedigreeTreeReadOnly from '../components/PedigreeTreeReadOnly'
|
||||
import RabbitTimeline from '../components/RabbitTimeline'
|
||||
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit, SERVER_BASE } from '../api'
|
||||
import { SEX_LABELS, STATUS_LABELS, formatAge, formatWeight, formatDate } from '../lib/format'
|
||||
import { getRabbit, getRabbitAncestors, getLitters, updateRabbit, deleteRabbit } from '../api'
|
||||
import { STATUS_LABELS, displayColor, formatAge, formatWeight, formatDate } from '../lib/format'
|
||||
import { useWeightUnit } from '../hooks/useWeightUnit'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { LOCI } from '../lib/genetics'
|
||||
|
|
@ -21,72 +23,70 @@ function locusLabel(locusKey, value) {
|
|||
return locus?.options.find((opt) => opt.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function StatRow({ label, value }) {
|
||||
// One fact in the hero's grid. `tone` tints the tile so the most-glanced
|
||||
// facts (age, weight, ear #) stand out from the reference ones.
|
||||
function Fact({ label, value, tone = 'bg-surface-sunken text-text-primary', mono = false }) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return (
|
||||
<div>
|
||||
<p className="font-body text-xs text-text-secondary">{label}</p>
|
||||
<p className="font-body text-sm text-text-primary">{value}</p>
|
||||
<div className={`min-w-0 rounded-2xl px-4 py-3 ${tone}`}>
|
||||
<p className="font-body text-xs font-bold opacity-80">{label}</p>
|
||||
<p className={`mt-0.5 truncate text-[15px] font-bold ${mono ? 'font-mono' : 'font-body'}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailColumn({ rabbit }) {
|
||||
function Hero({ rabbit, statusControl }) {
|
||||
const weightUnit = useWeightUnit()
|
||||
const setLoci = LOCI.filter((locus) => rabbit[locus.key])
|
||||
const color = displayColor(rabbit.color_name)
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex aspect-square items-center justify-center bg-surface-sunken">
|
||||
{rabbit.photo_path ? (
|
||||
<img
|
||||
src={`${SERVER_BASE}${rabbit.photo_path}`}
|
||||
alt={rabbit.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Silhouette className="h-20 w-20" />
|
||||
)}
|
||||
</div>
|
||||
<Card className="grid grid-cols-1 gap-5 p-3 md:grid-cols-[minmax(0,300px)_1fr] md:gap-7 md:p-4">
|
||||
<CoatPanel rabbit={rabbit} className="aspect-[16/10] rounded-[22px] md:aspect-square" />
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
{/* Name + farm are already the page title; just the status here. */}
|
||||
<Badge status={rabbit.status} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatRow label="Sex" value={SEX_LABELS[rabbit.sex] ?? rabbit.sex} />
|
||||
<StatRow label="Date of Birth" value={formatDate(rabbit.dob)} />
|
||||
<StatRow label="Age" value={formatAge(rabbit.dob)} />
|
||||
<StatRow label="Weight" value={formatWeight(rabbit.weight_lbs, weightUnit)} />
|
||||
<StatRow label="Breed" value={rabbit.breed} />
|
||||
<StatRow label="Ear Number" value={rabbit.ear_number} />
|
||||
<StatRow label="Location" value={rabbit.location} />
|
||||
</div>
|
||||
|
||||
{(rabbit.registration_number || rabbit.gc_number) && (
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-border pt-4">
|
||||
<StatRow label="Registration Number" value={rabbit.registration_number} />
|
||||
<StatRow label="GC Number" value={rabbit.gc_number} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rabbit.color_name || setLoci.length > 0) && (
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
{rabbit.color_name && <p className="font-body text-sm text-text-primary">{rabbit.color_name}</p>}
|
||||
{setLoci.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{setLoci.map((locus) => (
|
||||
<span
|
||||
key={locus.key}
|
||||
className="rounded-full border border-border bg-surface-sunken px-3 py-1 font-body text-xs text-text-primary"
|
||||
>
|
||||
{locusLabel(locus.key, rabbit[locus.key])}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 px-2 pb-3 md:px-0 md:py-2 md:pr-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge status={rabbit.status} />
|
||||
<SexBadge sex={rabbit.sex} />
|
||||
{rabbit.breed && (
|
||||
<span className="inline-flex h-6 items-center rounded-full bg-surface-sunken px-2.5 font-body text-xs font-bold text-text-secondary">
|
||||
{rabbit.breed}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-2.5 break-words font-display text-4xl font-bold leading-[1.02] tracking-tight text-text-primary md:text-[46px]">
|
||||
{rabbit.name}
|
||||
</h1>
|
||||
<p className="mt-1.5 font-body text-[15px] text-text-secondary">
|
||||
{[color, rabbit.farm && `from ${rabbit.farm}`].filter(Boolean).join(' · ') || 'No color recorded'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
|
||||
<Fact label="Age" value={rabbit.dob ? formatAge(rabbit.dob) : null} tone="bg-c1-soft text-c1-ink" />
|
||||
<Fact label="Weight" value={formatWeight(rabbit.weight_lbs, weightUnit)} tone="bg-c4-soft text-c4-ink" />
|
||||
<Fact label="Ear #" value={rabbit.ear_number} tone="bg-c3-soft text-c3-ink" mono />
|
||||
<Fact label="Born" value={formatDate(rabbit.dob)} />
|
||||
<Fact label="Location" value={rabbit.location} />
|
||||
<Fact label="Registration #" value={rabbit.registration_number} mono />
|
||||
<Fact label="GC #" value={rabbit.gc_number} mono />
|
||||
</div>
|
||||
|
||||
{setLoci.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{setLoci.map((locus) => (
|
||||
<span
|
||||
key={locus.key}
|
||||
className="rounded-full border-[1.5px] border-border px-3 py-1 font-mono text-xs font-semibold text-text-primary"
|
||||
>
|
||||
{locusLabel(locus.key, rabbit[locus.key])}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto">{statusControl}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
|
@ -95,8 +95,8 @@ function DetailColumn({ rabbit }) {
|
|||
function SectionCard({ title, action, children }) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-4">
|
||||
<h2 className="font-display text-lg text-text-primary">{title}</h2>
|
||||
<div className="flex items-center justify-between gap-3 px-5 pt-5">
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
|
|
@ -115,41 +115,44 @@ function LitterHistory({ rabbitId }) {
|
|||
|
||||
return (
|
||||
<SectionCard title="Litters">
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-surface-sunken">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Partner</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Kindle Date</th>
|
||||
<th className="px-4 py-2 font-body text-xs font-medium text-text-secondary">Kits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((litter) => {
|
||||
const partner = litter.sire?.id === rabbitId ? litter.dam : litter.sire
|
||||
return (
|
||||
<tr key={litter.id} className="border-t border-border">
|
||||
<td className="px-4 py-2 font-body text-sm">
|
||||
<Link to={`/litters/${litter.id}`} className="text-text-primary hover:text-accent">
|
||||
{partner?.name ?? 'Unknown'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-body text-sm text-text-secondary">
|
||||
<ul className="mt-3">
|
||||
{history.map((litter) => {
|
||||
const partner = litter.sire?.id === rabbitId ? litter.dam : litter.sire
|
||||
return (
|
||||
<li key={litter.id} className="border-t border-border">
|
||||
<Link to={`/litters/${litter.id}`} className="flex items-center gap-4 px-5 py-3 transition-colors hover:bg-surface-sunken">
|
||||
<span
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${
|
||||
litter.kindle_date ? 'bg-c3-soft text-c3-ink' : 'bg-c2-soft text-c2-ink'
|
||||
}`}
|
||||
>
|
||||
<Icon name={litter.kindle_date ? 'litters' : 'heart'} className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-body text-[15px] font-bold text-text-primary">
|
||||
with {partner?.name ?? 'Unknown'}
|
||||
</span>
|
||||
<span className="block font-body text-[13px] text-text-secondary">
|
||||
{litter.kindle_date
|
||||
? formatDate(litter.kindle_date)
|
||||
? `Kindled ${formatDate(litter.kindle_date)}`
|
||||
: litter.breed_date
|
||||
? `Bred ${formatDate(litter.breed_date)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-body text-sm text-text-secondary">
|
||||
{litter.kindle_date ? (litter.kit_count ?? 0) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
{litter.kindle_date && (
|
||||
<>
|
||||
<KitDots count={litter.kit_count} className="hidden sm:flex" />
|
||||
<span className="shrink-0 rounded-full bg-c3-soft px-2.5 py-1 font-body text-xs font-bold text-c3-ink">
|
||||
{litter.kit_count ?? 0} {litter.kit_count === 1 ? 'kit' : 'kits'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
|
@ -242,79 +245,82 @@ function RabbitDetail() {
|
|||
{ label: 'Print Pedigree', to: `/herd/${rabbit.id}/pedigree` },
|
||||
]
|
||||
|
||||
const statusControl = (
|
||||
<label className="inline-flex min-h-[44px] items-center gap-2 rounded-full border-[1.5px] border-border bg-surface-raised pl-4 pr-2 font-body text-sm font-semibold text-text-secondary">
|
||||
Status
|
||||
<select
|
||||
value={rabbit.status}
|
||||
disabled={updating}
|
||||
onChange={(e) => changeStatus(e.target.value)}
|
||||
className="!bg-transparent py-1 pr-1 font-bold text-text-primary focus:outline-none"
|
||||
>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title={rabbit.name}
|
||||
subtitle={rabbit.farm}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button to={`/herd/${rabbit.id}/edit`} size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
<label className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-border bg-surface-raised px-3 font-body text-sm text-text-secondary md:min-h-[32px]">
|
||||
Status
|
||||
<select
|
||||
value={rabbit.status}
|
||||
disabled={updating}
|
||||
onChange={(e) => changeStatus(e.target.value)}
|
||||
className="bg-transparent py-1 font-medium text-text-primary focus:outline-none"
|
||||
>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ActionMenu items={moreActions} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div className="w-full lg:w-[360px] lg:shrink-0">
|
||||
<DetailColumn rabbit={rabbit} />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-6">
|
||||
<SectionCard
|
||||
title="Pedigree"
|
||||
action={
|
||||
<Link to={`/herd/${rabbit.id}/pedigree`} className="font-body text-sm text-text-secondary hover:text-accent">
|
||||
Full pedigree
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{ancestors ? (
|
||||
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} generations={2} />
|
||||
) : (
|
||||
<p className="p-4 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<LitterHistory rabbitId={rabbitId} />
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<RabbitTimeline rabbit={rabbit} />
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" disabled={updating} onClick={handleDelete} className="text-danger">
|
||||
Delete Rabbit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Link
|
||||
to="/herd"
|
||||
className="inline-flex min-h-[40px] items-center gap-1 rounded-full pr-3 font-body text-sm font-bold text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<Icon name="chevronLeft" className="h-[18px] w-[18px]" />
|
||||
Herd
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button to={`/herd/${rabbit.id}/edit`} size="sm">
|
||||
<Icon name="edit" className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<ActionMenu items={moreActions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<p role="status" className="rounded-2xl border-[1.5px] border-c5 bg-c5-soft px-4 py-3 font-body text-sm font-semibold text-c5-ink">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
|
||||
<Hero rabbit={rabbit} statusControl={statusControl} />
|
||||
|
||||
<SectionCard
|
||||
title="Pedigree"
|
||||
action={
|
||||
<Link
|
||||
to={`/herd/${rabbit.id}/pedigree`}
|
||||
className="inline-flex items-center gap-1.5 font-body text-sm font-bold text-accent hover:underline"
|
||||
>
|
||||
<Icon name="print" className="h-4 w-4" />
|
||||
Print pedigree
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{ancestors ? (
|
||||
<PedigreeTreeReadOnly subjectName={rabbit.name} ancestors={ancestors} generations={3} />
|
||||
) : (
|
||||
<p className="p-5 font-body text-sm text-text-secondary">Loading pedigree…</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<LitterHistory rabbitId={rabbitId} />
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<RabbitTimeline rabbit={rabbit} />
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" disabled={updating} onClick={handleDelete} className="!text-danger">
|
||||
Delete rabbit
|
||||
</Button>
|
||||
</div>
|
||||
{confirmDialog}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
|||
import { Field, FieldGroup, Input, Segmented } from '../components/FormControls'
|
||||
import { useAuth } from '../authContext'
|
||||
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
|
||||
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
|
||||
import { MODES, PALETTES, applyTheme, composeTheme, normalizeTheme, parseTheme } from '../lib/theme'
|
||||
|
||||
const WEIGHT_UNIT_OPTIONS = [
|
||||
{ value: 'decimal_lbs', label: 'Decimal lbs' },
|
||||
|
|
@ -19,7 +19,7 @@ const WEIGHT_UNIT_OPTIONS = [
|
|||
function Toast({ message }) {
|
||||
if (!message) return null
|
||||
return (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 rounded-md bg-text-primary px-4 py-2 font-body text-sm text-surface shadow-lg md:bottom-6">
|
||||
<div className="fixed bottom-28 left-1/2 z-30 -translate-x-1/2 rounded-full bg-text-primary px-5 py-2.5 font-body text-sm font-bold text-surface shadow-pop md:bottom-6">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -45,7 +45,7 @@ function Settings() {
|
|||
weight_unit: settings.weight_unit ?? 'decimal_lbs',
|
||||
kit_prefix: settings.kit_prefix ?? '',
|
||||
default_breed: settings.default_breed ?? '',
|
||||
theme: settings.theme ?? DEFAULT_THEME,
|
||||
theme: normalizeTheme(settings.theme),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +117,8 @@ function Settings() {
|
|||
}
|
||||
}
|
||||
|
||||
const theme = parseTheme(form?.theme)
|
||||
|
||||
if (isLoading || !form) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -128,10 +130,10 @@ function Settings() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Settings" />
|
||||
<div className="space-y-6">
|
||||
<Card className="space-y-4 p-6">
|
||||
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
|
||||
<Card className="space-y-5 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Rabbitry</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Rabbitry</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">Shared by everyone who signs in.</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -167,9 +169,9 @@ function Settings() {
|
|||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-4 p-6">
|
||||
<Card className="space-y-5 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Your Preferences</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Your Preferences</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">Only affect your account.</p>
|
||||
</div>
|
||||
<FieldGroup label="Weight Display">
|
||||
|
|
@ -181,39 +183,47 @@ function Settings() {
|
|||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Theme" hint="Selecting a theme previews it instantly. Save to keep it across your devices.">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
type="button"
|
||||
onClick={() => handleThemeSelect(theme.id)}
|
||||
aria-pressed={form.theme === theme.id}
|
||||
className={`flex items-center gap-3 rounded-md border px-3 py-2 text-left ${
|
||||
form.theme === theme.id
|
||||
? 'border-accent ring-2 ring-accent'
|
||||
: 'border-border hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border"
|
||||
style={{ backgroundColor: theme.swatch.surface }}
|
||||
>
|
||||
<span
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: theme.swatch.accent }}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-body text-sm font-medium text-text-primary">
|
||||
{theme.label}
|
||||
</span>
|
||||
<span className="block truncate font-body text-xs text-text-secondary">
|
||||
{theme.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<FieldGroup label="Theme" hint="Changes preview instantly. Save to keep them across your devices.">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
{PALETTES.map((palette) => {
|
||||
const selected = theme.palette === palette.id
|
||||
return (
|
||||
<button
|
||||
key={palette.id}
|
||||
type="button"
|
||||
onClick={() => handleThemeSelect(composeTheme(palette.id, theme.mode))}
|
||||
aria-pressed={selected}
|
||||
className={`flex items-center gap-3 rounded-2xl border-[1.5px] p-3 text-left transition-colors ${
|
||||
selected ? 'border-accent bg-surface-sunken' : 'border-border hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
<span className="grid shrink-0 grid-cols-3 gap-1" aria-hidden="true">
|
||||
{palette.swatch.map((color) => (
|
||||
<span key={color} className="h-3.5 w-3.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-body text-sm font-bold text-text-primary">{palette.label}</span>
|
||||
<span className="block truncate font-body text-xs text-text-secondary">{palette.description}</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-on-accent">
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m5 12.5 4.5 4.5L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Segmented
|
||||
label="Light or dark"
|
||||
options={MODES}
|
||||
value={theme.mode}
|
||||
onChange={(nextMode) => handleThemeSelect(composeTheme(theme.palette, nextMode))}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
|
|
@ -224,7 +234,7 @@ function Settings() {
|
|||
|
||||
<Card className="space-y-3 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Back Up</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Back Up</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
Download a complete copy of the database — rabbits, litters, shows, users and settings. To restore it,
|
||||
replace the server’s data file (see “Data & backups” in the README); Import below can’t read it.
|
||||
|
|
@ -237,7 +247,7 @@ function Settings() {
|
|||
|
||||
<Card className="space-y-3 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Import Herd</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Import Herd</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
Load rabbits from a CSV or JSON file, such as an export from the old Kindlebox site. This replaces your entire herd —{' '}
|
||||
<span className="font-medium text-danger">all current rabbits and litters are deleted first.</span>
|
||||
|
|
@ -260,7 +270,7 @@ function Settings() {
|
|||
<UsersCard onToast={setToast} />
|
||||
<Card className="space-y-3 p-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary">Account</h2>
|
||||
<h2 className="font-display text-xl font-bold text-text-primary">Account</h2>
|
||||
<p className="mt-1 font-body text-sm text-text-secondary">
|
||||
{user ? `Signed in as ${user.username}. ` : ''}End this session on this device.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ function ShowCard({ eventId, show }) {
|
|||
<Card className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">{show.club_name}</p>
|
||||
<p className="font-display text-base font-bold text-text-primary">{show.club_name}</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatDate(show.show_date) ?? '—'} · {show.show_type ?? '—'}
|
||||
{show.specialty_breed ? ` · ${show.specialty_breed}` : ''}
|
||||
|
|
@ -220,7 +220,7 @@ function PlacementBadges({ placement }) {
|
|||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs text-text-primary">{placement.placement}</span>
|
||||
) : null}
|
||||
{placement.legs_earned > 0 ? (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-surface">
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-on-accent">
|
||||
{placement.legs_earned} leg{placement.legs_earned === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
@ -435,7 +435,7 @@ function ShowEntriesGroup({ eventId, show, multiShowIds }) {
|
|||
<Card className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">{show.club_name}</p>
|
||||
<p className="font-display text-base font-bold text-text-primary">{show.club_name}</p>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatDate(show.show_date) ?? '—'} · {show.show_type ?? '—'}
|
||||
</p>
|
||||
|
|
@ -566,12 +566,12 @@ function ResultsTab({ event }) {
|
|||
<Card key={row.rabbitId} className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-display text-base text-text-primary">{row.name}</p>
|
||||
<p className="font-display text-base font-bold text-text-primary">{row.name}</p>
|
||||
{meta ? <p className="font-body text-xs text-text-secondary">{meta}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{row.legs > 0 ? (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-surface">
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-on-accent">
|
||||
{row.legs} leg{row.legs === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
@ -610,7 +610,7 @@ function TabButton({ active, onClick, children }) {
|
|||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-md px-4 py-1.5 font-body text-sm ${
|
||||
active ? 'bg-accent text-surface' : 'text-text-primary hover:bg-surface-sunken'
|
||||
active ? 'bg-accent text-on-accent' : 'text-text-primary hover:bg-surface-sunken'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function NewEventForm({ onCreated }) {
|
|||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<h3 className="font-display text-base text-text-primary">New event</h3>
|
||||
<h3 className="font-display text-base font-bold text-text-primary">New event</h3>
|
||||
<Field label="Event name" required>
|
||||
<Input
|
||||
type="text"
|
||||
|
|
@ -116,7 +116,7 @@ function ShowEvents() {
|
|||
{events.map((event) => (
|
||||
<Link key={event.id} to={`/shows/events/${event.id}`}>
|
||||
<Card className="flex h-full flex-col gap-2 p-4 hover:border-accent">
|
||||
<h3 className="font-display text-lg text-text-primary">{event.name}</h3>
|
||||
<h3 className="font-display text-lg font-bold text-text-primary">{event.name}</h3>
|
||||
<p className="font-body text-sm text-text-secondary">
|
||||
{formatRange(event.first_show_date, event.last_show_date)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -76,12 +76,12 @@ function MultiSelect({ label, options, selected, onChange }) {
|
|||
>
|
||||
<span>{label}</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="rounded-full bg-accent px-1.5 text-xs text-surface">{selected.length}</span>
|
||||
<span className="rounded-full bg-accent px-1.5 text-xs text-on-accent">{selected.length}</span>
|
||||
)}
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-10 mt-1 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-surface-raised p-2 shadow-lg">
|
||||
<div className="absolute z-10 mt-1 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-surface-raised p-2 shadow-pop">
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
// The six party hues each come as a strong fill, a soft tint and an ink
|
||||
// readable on that tint (see the token notes in src/index.css).
|
||||
const hues = Object.fromEntries(
|
||||
[1, 2, 3, 4, 5, 6].flatMap((n) => [
|
||||
[`c${n}`, `var(--c${n})`],
|
||||
[`c${n}-soft`, `var(--c${n}-soft)`],
|
||||
[`c${n}-ink`, `var(--c${n}-ink)`],
|
||||
])
|
||||
)
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
|
|
@ -6,27 +17,45 @@ export default {
|
|||
// Color tokens are CSS variables (defined per-theme in src/index.css)
|
||||
// rather than literal hex values, so switching the document's
|
||||
// `data-theme` re-themes every existing utility class with no markup
|
||||
// changes. The `:root` block in index.css holds the default (Classic)
|
||||
// palette; see src/lib/theme.js for the theme registry.
|
||||
// changes. See src/lib/theme.js for the theme registry.
|
||||
colors: {
|
||||
surface: 'var(--color-surface)',
|
||||
'surface-raised': 'var(--color-surface-raised)',
|
||||
'surface-sunken': 'var(--color-surface-sunken)',
|
||||
border: 'var(--color-border)',
|
||||
'border-strong': 'var(--color-border-strong)',
|
||||
'text-primary': 'var(--color-text-primary)',
|
||||
'text-secondary': 'var(--color-text-secondary)',
|
||||
'text-faint': 'var(--color-text-faint)',
|
||||
accent: 'var(--color-accent)',
|
||||
'accent-hover': 'var(--color-accent-hover)',
|
||||
'on-accent': 'var(--color-on-accent)',
|
||||
'accent-warm': 'var(--color-accent-warm)',
|
||||
'on-accent-warm': 'var(--color-on-accent-warm)',
|
||||
danger: 'var(--color-danger)',
|
||||
...hues,
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['DM Sans', 'sans-serif'],
|
||||
body: ['Inter', 'sans-serif'],
|
||||
display: ['"Bricolage Grotesque"', 'system-ui', 'sans-serif'],
|
||||
body: ['Figtree', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
// Rounder than Tailwind's defaults across the board — the Confetti look
|
||||
// is soft and friendly, and bumping the scale here restyles every
|
||||
// existing `rounded-*` without touching markup.
|
||||
borderRadius: {
|
||||
DEFAULT: '0.375rem',
|
||||
md: '0.75rem',
|
||||
lg: '1rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.75rem',
|
||||
},
|
||||
boxShadow: {
|
||||
card: 'var(--shadow-card)',
|
||||
pop: 'var(--shadow-pop)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const defaultSettings = [
|
|||
{ key: 'rabbitry_name', value: 'My Rabbitry' },
|
||||
{ key: 'weight_unit', value: 'decimal_lbs' },
|
||||
{ key: 'kit_prefix', value: '' },
|
||||
{ key: 'theme', value: 'classic' },
|
||||
{ key: 'theme', value: 'confetti-system' },
|
||||
// Pre-fills the breed field on new rabbits (most herds are a single breed).
|
||||
// Defaults to Holland Lop, the current dataset's breed; editable in Settings.
|
||||
{ key: 'default_breed', value: 'Holland Lop' },
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^1.3.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue