Polish layout for mobile and desktop
- Mobile bottom nav: icons, 56px targets, safe-area padding, and a "More" menu so Genetics/Shows/Settings are reachable on phones - Sticky sidebar, max-width content column, min-w-0 so wide children (pedigree tree, tables) scroll in place instead of widening the page - PageHeader owns no gutter and stacks actions on small screens; pages share one flex/gap structure and a consistent loading header - 16px inputs on mobile to stop iOS focus zoom; disabled button styling - Remove placeholder "0 litters / 0 kits" on rabbit cards, duplicate name on Rabbit Detail; proper Genetics placeholder and 404 pages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9d45911934
commit
afff8388b0
|
|
@ -45,12 +45,12 @@ 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`) with Dashboard/Herd/Litters/Add (→ `/herd/add`)/Settings. 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 />`), so the quick-search bar appears on every page. |
|
||||
| `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 />`), so the quick-search bar appears on every page. |
|
||||
| `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`. 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. |
|
||||
| `src/components/Badge.jsx` | Status → color/label class map covering all six Rabbit `status` values; unrecognized values fall back to the `other` style rather than crashing. |
|
||||
| `src/components/Card.jsx` | Bordered `surface-raised` container with shadow; just forwards `className`/props. |
|
||||
| `src/components/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. |
|
||||
| `src/components/PageHeader.jsx` | Title + optional subtitle + optional right-side action slot. No horizontal padding (the Layout gutter supplies it); stacks the action row under the title below `sm`. Pages render it as the first child of a `flex flex-col gap-6` root, and loading states render `<PageHeader title=… subtitle="Loading…" />`. |
|
||||
| `src/components/EmptyState.jsx` | Centered title + optional description + optional action, used by list pages with no results. |
|
||||
| `src/pages/*.jsx` | One file per IA route (`Dashboard`, `Herd`, `AddRabbit`, `RabbitDetail`, `EditRabbit`, `Litters`, `AddLitter`, `LitterDetail`, `Genetics`, `Settings`, `NotFound`). `Dashboard.jsx` is built out (Phase 5): a stats bar (8 `Card` tiles from `GET /api/stats`, each linking to `/herd` with a sex or status query param), a quick-actions row (`Button`s linking to `/herd/add`, `/litters/add`, `/herd`), and a two-column (`md:grid-cols-2`) widget row with Recent Litters (top 3 by `kindle_date` from `GET /api/litters`) and Upcoming Birthdays (a user-requested addition not in the original plan — computed client-side from `GET /api/rabbits?status=active`'s `dob` field, no backend support; see the "Upcoming Birthdays" convention note below). `Herd.jsx` is built out (Phase 6): a sex/sort/search/view filter bar synced to URL query params (`?sex=&sort=&q=&view=`), and a `RabbitGrid`/`RabbitListView` pair sharing one `useQuery` over `GET /api/rabbits` — see the Herd filtering convention note below. `AddRabbit.jsx` (Phase 7) is a 3-step wizard (Pedigree → Details+Genetics → Review) composing the Step 7.1–7.4 components, holding `details`/`genetics`/`pedigreeFields` state and calling `createRabbit` then (if a photo was picked) `uploadRabbitPhoto` on save. `EditRabbit.jsx` (Phase 7) is a single page with all three sections visible, pre-populated from `GET /api/rabbits/:id` + `GET /api/rabbits/:id/ancestors`, sending only the changed fields via `PATCH` — see the pedigree convention note below for why it must wait on the ancestors fetch before mounting `PedigreeTree`. All other pages remain placeholders rendering just a heading — real content for them starts in Phase 8+. |
|
||||
| `src/pages/Shows.jsx` | The `/shows` ARBA show browser (Shows feature Phase 1 — browse-only, no event/entry actions yet). One `useQuery(['arba-shows'])` over `getArbaShows`; a "Refresh Data" button mutates via `refreshArbaShows` and writes the result straight into the query cache (`setQueryData`). All filtering/pagination is **client-side** over the loaded list: State/District/Show Type multi-selects (an inline `MultiSelect` checkbox-popover component with outside-click close; State/District options derived from the data, Show Type a fixed 5-bucket list matching `lib/arba.ts`'s `ShowType`), Date From (defaults to today) / Date To, and a keyword box (club/city/secretary). 50 rows/page. Reads the snake_case show fields the API returns (`raw_date`, `club_name`, `show_type`/`show_type_full`, `special_breed`, `maps_url`, `catalog_url`, etc.). |
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#4A6741" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -4,15 +4,17 @@ const VARIANT_CLASSES = {
|
|||
danger: 'bg-danger text-surface 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.
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
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]',
|
||||
}
|
||||
|
||||
function Button({ variant = 'primary', size = 'md', className = '', ...props }) {
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md font-body font-medium transition-colors ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
|
||||
className={`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}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,93 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import QuickSearch from './QuickSearch'
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard', end: true },
|
||||
{ to: '/herd', label: 'Herd', icon: 'herd' },
|
||||
{ to: '/litters', label: 'Litters', icon: 'litters' },
|
||||
{ to: '/genetics', label: 'Genetics', icon: 'genetics' },
|
||||
{ to: '/shows', label: 'Shows', icon: 'shows' },
|
||||
{ to: '/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
|
||||
// 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).
|
||||
const MORE_ITEMS = NAV_ITEMS.filter((item) => ['/genetics', '/shows', '/settings'].includes(item.to))
|
||||
|
||||
const navLinkClass = ({ isActive }) =>
|
||||
`block rounded-md px-3 py-2 font-body text-sm ${
|
||||
`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'
|
||||
}`
|
||||
|
||||
const bottomNavLinkClass = ({ isActive }) =>
|
||||
`flex flex-1 flex-col items-center justify-center py-2 text-xs ${
|
||||
`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'
|
||||
}`
|
||||
|
||||
|
|
@ -37,62 +116,130 @@ function OfflineBanner() {
|
|||
|
||||
if (!isOffline) return null
|
||||
|
||||
// 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 className="fixed inset-x-0 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 text-surface">
|
||||
You’re offline. Showing cached data where available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const { pathname } = useLocation()
|
||||
const isActive = MORE_ITEMS.some((item) => pathname.startsWith(item.to))
|
||||
|
||||
// Closes on outside tap / Escape. Closing on navigation is handled by the
|
||||
// parent remounting this component with `key={pathname}`.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onPointerDown(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={bottomNavLinkClass({ isActive: isActive || open })}
|
||||
>
|
||||
<Icon name="more" />
|
||||
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"
|
||||
>
|
||||
{MORE_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} role="menuitem" className={navLinkClass}>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
const { pathname } = useLocation()
|
||||
// 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">
|
||||
<OfflineBanner />
|
||||
<aside className="hidden w-60 shrink-0 flex-col gap-1 border-r border-border p-4 md:flex">
|
||||
<h1 className="mb-4 font-display text-xl">MyKindlebox</h1>
|
||||
<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">
|
||||
<NavLink to="/" end className={navLinkClass}>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/herd" className={navLinkClass}>
|
||||
Herd
|
||||
</NavLink>
|
||||
<NavLink to="/litters" className={navLinkClass}>
|
||||
Litters
|
||||
</NavLink>
|
||||
<NavLink to="/genetics" className={navLinkClass}>
|
||||
Genetics
|
||||
</NavLink>
|
||||
<NavLink to="/shows" className={navLinkClass}>
|
||||
Shows
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={navLinkClass}>
|
||||
Settings
|
||||
</NavLink>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={navLinkClass}>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 p-4 pb-20 md:p-8 md:pb-8">
|
||||
<QuickSearch />
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* 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>
|
||||
<QuickSearch />
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="fixed inset-x-0 bottom-0 flex border-t border-border bg-surface-raised md:hidden">
|
||||
<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"
|
||||
>
|
||||
<NavLink to="/" end className={bottomNavLinkClass}>
|
||||
<Icon name="dashboard" />
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/herd" className={bottomNavLinkClass}>
|
||||
<NavLink to="/herd" className={() => bottomNavLinkClass({ isActive: herdActive })}>
|
||||
<Icon name="herd" />
|
||||
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>
|
||||
<NavLink to="/litters" className={bottomNavLinkClass}>
|
||||
<Icon name="litters" />
|
||||
Litters
|
||||
</NavLink>
|
||||
<NavLink to="/herd/add" className={bottomNavLinkClass}>
|
||||
Add
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={bottomNavLinkClass}>
|
||||
Settings
|
||||
</NavLink>
|
||||
<MoreMenu key={pathname} />
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
// 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 }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl text-text-primary">{title}</h1>
|
||||
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<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>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
{action && <div className="sm:shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -371,13 +371,18 @@ function PedigreeTree({
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-[900px] items-center gap-6 overflow-x-auto 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>
|
||||
// The tree needs ~900px; on narrower screens the outer wrapper scrolls
|
||||
// sideways rather than the min-width stretching the whole page.
|
||||
<div className="-mx-4 overflow-x-auto md:mx-0">
|
||||
<p className="px-4 pt-2 font-body text-xs text-text-secondary xl:hidden">Swipe sideways to see every generation.</p>
|
||||
<div className="flex w-full min-w-[900px] items-center gap-6 p-4 pb-[300px]">
|
||||
<div className="flex h-16 w-32 shrink-0 items-center justify-center rounded-md border-2 border-accent bg-surface px-3 text-center">
|
||||
<span className="truncate font-body text-sm font-medium text-text-primary">{subjectName}</span>
|
||||
</div>
|
||||
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
</div>
|
||||
<Generation entries={GEN1} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
<Generation entries={GEN2} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
<Generation entries={GEN3} state={state} setSlotState={setSlotState} cascadeCache={cascadeCache} fetchCascade={fetchCascade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ function QuickSearch() {
|
|||
const showDropdown = open && query.trim() !== ''
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative mb-4 max-w-xl">
|
||||
<div ref={containerRef} className="relative mb-6 max-w-xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
|
@ -143,7 +143,7 @@ function QuickSearch() {
|
|||
|
||||
{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-label text-xs uppercase tracking-wide text-text-secondary">
|
||||
<p className="px-1 pb-1 font-body text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
Search in
|
||||
</p>
|
||||
{SEARCH_FIELDS.map((field) => (
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ function RabbitCard({ rabbit }) {
|
|||
{age}
|
||||
{weight ? ` • ${weight}` : ''}
|
||||
</p>
|
||||
<p className="font-body text-xs text-text-secondary">0 litters • 0 kits</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -155,6 +155,17 @@
|
|||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@media (max-width: 767px) {
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='color']),
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function AddBreeding() {
|
|||
const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId)
|
||||
|
||||
if ((sireId && sireLoading) || (damId && damLoading)) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Record Breeding</h1>
|
||||
return <PageHeader title="Record Breeding" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
async function handleSubmit(payload) {
|
||||
|
|
@ -44,9 +44,9 @@ function AddBreeding() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Record Breeding" />
|
||||
<div className="px-6 pb-6">
|
||||
<div>
|
||||
<LitterForm
|
||||
mode="breeding"
|
||||
initialValues={{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function AddLitter() {
|
|||
const { data: damRabbit, isLoading: damLoading } = useParentFromQuery(damId)
|
||||
|
||||
if ((sireId && sireLoading) || (damId && damLoading)) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Record Litter</h1>
|
||||
return <PageHeader title="Record Litter" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
async function handleSubmit(payload) {
|
||||
|
|
@ -41,9 +41,9 @@ function AddLitter() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Record Litter" />
|
||||
<div className="px-6 pb-6">
|
||||
<div>
|
||||
<LitterForm
|
||||
initialValues={{
|
||||
sire: sireRabbit ? { id: sireRabbit.id, name: sireRabbit.name } : null,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,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 <h1 className="p-6 font-display text-2xl text-text-primary">Add Rabbit</h1>
|
||||
return <PageHeader title="Add Rabbit" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
const pedigreeInitialState =
|
||||
|
|
@ -136,14 +136,14 @@ function AddRabbit() {
|
|||
) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Add Rabbit"
|
||||
subtitle={`Step ${step} of 3 — ${STEP_LABELS[step - 1]}`}
|
||||
action={headerAction}
|
||||
/>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<div>
|
||||
{step === 1 && (
|
||||
<PedigreeTree
|
||||
initialState={pedigreeInitialState}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getStats, getLitters, getRabbits } from '../api'
|
||||
import { getStats, getLitters, getRabbits, getSettings } from '../api'
|
||||
import Card from '../components/Card'
|
||||
import Button from '../components/Button'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
const BIRTHDAY_WINDOW_DAYS = 30
|
||||
|
||||
|
|
@ -197,8 +198,11 @@ function UpcomingBirthdays() {
|
|||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<PageHeader title={settings?.rabbitry_name || 'Dashboard'} subtitle="Herd overview" />
|
||||
<section>
|
||||
<StatsBar />
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function EditLitter() {
|
|||
})
|
||||
|
||||
if (isLoading || !litter) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Edit Litter</h1>
|
||||
return <PageHeader title="Edit Litter" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
async function handleSubmit(payload) {
|
||||
|
|
@ -33,9 +33,9 @@ function EditLitter() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Edit Litter" />
|
||||
<div className="px-6 pb-6">
|
||||
<div>
|
||||
<LitterForm
|
||||
initialValues={{
|
||||
sire: litter.sire,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ function EditRabbit() {
|
|||
}
|
||||
|
||||
if (rabbitLoading || !rabbit || details === null || genetics === null) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Edit Rabbit</h1>
|
||||
return <PageHeader title="Edit Rabbit" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
function updateDetails(partial) {
|
||||
|
|
@ -89,10 +89,10 @@ function EditRabbit() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Edit Rabbit" subtitle={rabbit.name} />
|
||||
|
||||
<div className="space-y-6 px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
<RabbitDetailsForm
|
||||
value={details}
|
||||
onChange={updateDetails}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
import Card from '../components/Card'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
|
||||
// Placeholder until the pairing calculator is built (see app-design-document
|
||||
// "Genetics Calculator"); renders a proper page shell instead of a bare <h1>.
|
||||
function Genetics() {
|
||||
return <h1>Genetics Calculator</h1>
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Genetics Calculator" subtitle="Predict kit colors from a buck × doe pairing." />
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Coming soon"
|
||||
description="The pairing calculator isn't built yet. Genotypes you record on each rabbit will feed into it."
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Genetics
|
||||
|
|
|
|||
|
|
@ -84,14 +84,15 @@ function SearchBox({ q, onSearchChange }) {
|
|||
}, [searchInput])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5">
|
||||
<div className="flex min-w-0 flex-1 basis-full items-center gap-2 rounded-md border border-border bg-surface-raised px-3 py-1.5 sm:basis-auto md:flex-none">
|
||||
<SearchIcon />
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search by name"
|
||||
className="w-40 bg-transparent font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
aria-label="Search herd by name"
|
||||
className="w-full min-w-0 bg-transparent md:w-40 font-body text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function LitterDetail() {
|
|||
const weightUnit = useWeightUnit()
|
||||
|
||||
if (isLoading || !litter) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Litter Detail</h1>
|
||||
return <PageHeader title="Litter Detail" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
const sireName = litter.sire?.name ?? 'Unknown'
|
||||
|
|
@ -52,7 +52,7 @@ function LitterDetail() {
|
|||
if (kindled) addKitParams.set('dob', litter.kindle_date)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title={`${sireName} × ${damName}`}
|
||||
subtitle={
|
||||
|
|
@ -75,7 +75,7 @@ function LitterDetail() {
|
|||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
|
|
@ -107,8 +107,8 @@ function LitterDetail() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function Litters() {
|
|||
const { data: litters, isLoading } = useQuery({ queryKey: ['litters'], queryFn: getLitters })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Litters"
|
||||
action={
|
||||
|
|
@ -20,7 +20,7 @@ function Litters() {
|
|||
}
|
||||
/>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
<div>
|
||||
{isLoading ? null : litters.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No litters yet"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import Button from '../components/Button'
|
||||
import Card from '../components/Card'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
|
||||
function NotFound() {
|
||||
return <h1>Not Found</h1>
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Page not found"
|
||||
description="That page doesn't exist — it may have been moved or deleted."
|
||||
action={
|
||||
<Link to="/">
|
||||
<Button>Back to Dashboard</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
||||
|
|
|
|||
|
|
@ -47,13 +47,8 @@ function DetailColumn({ rabbit }) {
|
|||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl text-text-primary">{rabbit.name}</h1>
|
||||
{rabbit.farm && <p className="font-body text-sm text-text-secondary">{rabbit.farm}</p>}
|
||||
<div className="mt-2">
|
||||
<Badge status={rabbit.status} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 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} />
|
||||
|
|
@ -104,7 +99,7 @@ function LitterHistory({ rabbitId }) {
|
|||
if (history.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<Card className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-surface-sunken">
|
||||
<tr>
|
||||
|
|
@ -160,7 +155,7 @@ function RabbitDetail() {
|
|||
})
|
||||
|
||||
if (isLoading || !rabbit) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Rabbit Detail</h1>
|
||||
return <PageHeader title="Rabbit Detail" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
async function patchStatus(status) {
|
||||
|
|
@ -194,7 +189,7 @@ function RabbitDetail() {
|
|||
const addLitterHref = `/litters/add${parentQuery}`
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title={rabbit.name}
|
||||
subtitle={rabbit.farm}
|
||||
|
|
@ -235,7 +230,7 @@ function RabbitDetail() {
|
|||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
{error && <p className="font-body text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
|
|
|
|||
|
|
@ -114,16 +114,16 @@ function Settings() {
|
|||
|
||||
if (isLoading || !form) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Settings" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader title="Settings" />
|
||||
<div className="space-y-6 p-6 pt-0">
|
||||
<div className="space-y-6">
|
||||
<Card className="space-y-4 p-6">
|
||||
<div>
|
||||
<label className="block font-body text-sm font-medium text-text-primary">Rabbitry Name</label>
|
||||
|
|
|
|||
|
|
@ -638,7 +638,7 @@ function ShowEventDetail() {
|
|||
return <p className="p-6 font-body text-sm text-danger">Failed to load this event.</p>
|
||||
}
|
||||
if (isLoading || !event) {
|
||||
return <h1 className="p-6 font-display text-2xl text-text-primary">Show Event</h1>
|
||||
return <PageHeader title="Show Event" subtitle="Loading…" />
|
||||
}
|
||||
|
||||
const shows = event.shows ?? []
|
||||
|
|
@ -650,7 +650,7 @@ function ShowEventDetail() {
|
|||
title={event.name}
|
||||
subtitle={`${shows.length} show${shows.length === 1 ? '' : 's'} · ${entryCount} entr${entryCount === 1 ? 'y' : 'ies'}`}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link to="/shows">
|
||||
<Button variant="secondary">Add Shows</Button>
|
||||
</Link>
|
||||
|
|
|
|||
Loading…
Reference in New Issue