Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on <html> by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+54
-1
@@ -4,9 +4,62 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>%F0%9F%8C%B1</text></svg>" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237a8a5e' stroke-width='2.75' stroke-linecap='round' stroke-linejoin='round'><path d='M7 20h10'/><path d='M10 20c5.5-2.5.8-6.4 3-10'/><path d='M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z'/><path d='M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z'/></svg>" />
|
||||
<title>pansy</title>
|
||||
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
||||
<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=Caprasimo&family=Figtree:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
<script>
|
||||
// Pansy theme — light is the stylesheet default; dark overrides the tokens on
|
||||
// <html>. A classic (blocking) script on purpose: it runs before the first
|
||||
// paint, so a dark-mode user never sees a cream flash. The preference
|
||||
// ('system' | 'light' | 'dark') persists in localStorage; src/lib/theme.ts is
|
||||
// the typed React face of this same object (window.PansyTheme) and this is
|
||||
// the ONLY place the dark token values live. Ported from
|
||||
// docs/design_handoff_pansy_ui/pansy-theme.js.
|
||||
(function () {
|
||||
var KEY = 'pansy-theme';
|
||||
var DARK = {
|
||||
'--color-bg': '#252220', '--color-surface': '#33302a', '--color-text': '#f1e9da',
|
||||
'--color-divider': 'color-mix(in srgb, #f5ead8 16%, transparent)',
|
||||
'--color-accent': '#d67f48',
|
||||
'--color-neutral-100': '#2e2b25', '--color-neutral-200': '#3a362f', '--color-neutral-300': '#474238',
|
||||
'--color-neutral-400': '#645c50', '--color-neutral-500': '#82796a', '--color-neutral-800': '#dcd3c4',
|
||||
'--color-accent-100': '#3d2c1d', '--color-accent-200': '#59331a', '--color-accent-300': '#8c491a',
|
||||
'--color-accent-400': '#d67f48', '--color-accent-700': '#f6a06b', '--color-accent-800': '#ffd9bd', '--color-accent-900': '#ffe9da',
|
||||
'--color-accent-2-200': '#333d24', '--color-accent-2-100': '#2d3520', '--color-accent-2-300': '#3d472b', '--color-accent-2-500': '#728157',
|
||||
'--color-accent-2-600': '#aebf92', '--color-accent-2-700': '#ccdbb2', '--color-accent-2-800': '#e1eecc',
|
||||
'--shadow-sm': '0 1px 2px rgba(0,0,0,0.4)', '--shadow-md': '0 3px 10px rgba(0,0,0,0.45)', '--shadow-lg': '0 12px 32px rgba(0,0,0,0.55)',
|
||||
'--p-field': '#2b2823', '--p-grid-ink': '#f5ead8',
|
||||
'--p-ink-strong': '#d9d0bf', '--p-ink-soft': '#b3a992', '--p-ink-mute': '#8f8674',
|
||||
'--p-bed-fill': '#4a4131', '--p-bed-stroke': '#6d5f47', '--p-ing-fill': '#3f382c', '--p-ing-stroke': '#5c5343',
|
||||
'--p-path-fill': '#312e28', '--p-path-stroke': '#4d473c', '--p-bag-fill': '#463d2f', '--p-bag-stroke': '#6a5d49',
|
||||
'--p-bkt-fill': '#3e382e', '--p-bkt-stroke': '#5f574a', '--p-tree-fill': '#333a28', '--p-tree-stroke': '#56633f',
|
||||
'--p-str-fill': '#3b362e', '--p-str-stroke': '#5f574a'
|
||||
};
|
||||
var P = {
|
||||
get: function () { try { return localStorage.getItem(KEY) || 'system'; } catch (e) { return 'system'; } },
|
||||
set: function (v) { try { localStorage.setItem(KEY, v); } catch (e) {} },
|
||||
sysDark: function () { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); },
|
||||
isDark: function (pref) { return pref === 'dark' || (pref === 'system' && P.sysDark()); },
|
||||
apply: function (dark) {
|
||||
var r = document.documentElement.style;
|
||||
Object.keys(DARK).forEach(function (k) { if (dark) r.setProperty(k, DARK[k]); else r.removeProperty(k); });
|
||||
r.colorScheme = dark ? 'dark' : 'light';
|
||||
},
|
||||
watch: function (cb) {
|
||||
if (!window.matchMedia) return function () {};
|
||||
var m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
m.addEventListener('change', cb);
|
||||
return function () { m.removeEventListener('change', cb); };
|
||||
},
|
||||
next: function (p) { return p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'; }
|
||||
};
|
||||
window.PansyTheme = P;
|
||||
P.apply(P.isDark(P.get()));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
-19
@@ -10,7 +10,6 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -1757,24 +1756,6 @@
|
||||
"integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@use-gesture/core": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@use-gesture/react": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@use-gesture/core": "10.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { buttonClasses } from '@/components/ui/Button'
|
||||
import { Nav } from '@/components/layout/Nav'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
/** The router's catch-all for unknown paths. */
|
||||
export function NotFound() {
|
||||
usePageTitle('Not found')
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-5xl" aria-hidden>
|
||||
🌱
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold text-fg">Page not found</h1>
|
||||
<p className="text-sm text-muted">That page doesn't exist — the link may be wrong or the page moved.</p>
|
||||
<Link to="/gardens" className={buttonClasses('primary')}>
|
||||
Back to gardens
|
||||
</Link>
|
||||
<div className="min-h-full">
|
||||
<Nav />
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 text-center">
|
||||
<Icon name="sprout" size={40} className="text-accent-2-500" />
|
||||
<h3>Nothing growing here</h3>
|
||||
<p className="text-[13px] text-ink-soft">That page doesn't exist — the link may be wrong or the page moved.</p>
|
||||
<Link to="/gardens" className="btn btn-primary mt-2 no-underline">
|
||||
Back to the gardens
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { errorMessage } from '@/lib/api'
|
||||
export function RouteError({ error }: { error: Error }) {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
|
||||
<h1 className="text-lg font-semibold text-fg">Something went wrong</h1>
|
||||
<p className="text-sm text-muted">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button onClick={() => router.invalidate()}>Try again</Button>
|
||||
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h3>Something went wrong</h3>
|
||||
<p className="text-[13px] text-ink-soft">{errorMessage(error, 'An unexpected error occurred.')}</p>
|
||||
<Button variant="primary" className="mt-2" onClick={() => router.invalidate()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Centered card layout shared by the login and register pages. */
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-sm flex-col justify-center">
|
||||
<div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-fg">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ThemeButton } from '@/components/layout/ThemeButton'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
|
||||
/**
|
||||
* The sign-in/sign-up backdrop: a centered 400px column over two soft accent
|
||||
* circles, the brand mark above the card, the theme toggle in the corner.
|
||||
*/
|
||||
export function AuthScreen({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-bg p-6">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -right-[140px] -top-[180px] h-[520px] w-[520px] rounded-full bg-accent-2-200 opacity-55"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -bottom-[140px] -left-[100px] h-[340px] w-[340px] rounded-full bg-accent-200 opacity-50"
|
||||
/>
|
||||
<ThemeButton className="absolute right-[18px] top-[18px] bg-neutral-100" />
|
||||
<div className="relative flex w-[min(400px,100%)] flex-col gap-[22px]">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Icon name="sprout" size={44} className="text-accent-2-600" />
|
||||
<h1 className="text-[40px]">pansy</h1>
|
||||
<p className="text-[14.5px] text-ink-soft">Plan the plot. Keep the notes. Grow the thing.</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The card the forms sit in. */
|
||||
export function AuthCard({ children }: { children: ReactNode }) {
|
||||
return <div className="panel elev-md flex flex-col gap-3.5 p-[26px]">{children}</div>
|
||||
}
|
||||
|
||||
/** "— or —" between the two sign-in methods. */
|
||||
export function OrDivider() {
|
||||
return (
|
||||
<div className="my-0.5 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-divider" />
|
||||
<span className="text-xs text-ink-mute">or</span>
|
||||
<span className="h-px flex-1 bg-divider" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { TextField } from '@/components/ui/Field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
import { parsePlanName, planNameFor } from '@/lib/plan'
|
||||
|
||||
/**
|
||||
* Duplicate a garden — the way to scheme a season: the copy is a separate
|
||||
* garden you rearrange freely while this one stays put. Beds and everything
|
||||
* currently planted come along; the share link and shares don't. The name is
|
||||
* prefilled as "<name> — <next year>", which is what the editor's season
|
||||
* control and the `plan` tag read back (see lib/plan.ts). On success we land in
|
||||
* the copy, since the point of copying is to start editing it.
|
||||
*/
|
||||
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const base = parsePlanName(garden.name)?.base ?? garden.name
|
||||
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const [name, setName] = useState(() => planNameFor(base, year))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
|
||||
toast.info(`Copied to “${created.name}”.`)
|
||||
onClose()
|
||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not copy the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Plan a season" onClose={onClose} busy={copy.isPending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
<p className="text-[13px] leading-relaxed text-ink-soft">
|
||||
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in — rearrange freely, the
|
||||
original stays put. Beds and what's planted come along; shares and the public link don't.
|
||||
</p>
|
||||
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={copy.isPending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={copy.isPending || name.trim() === ''}>
|
||||
{copy.isPending ? 'Copying…' : 'Make the copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
|
||||
/**
|
||||
* Duplicate a garden under a new name. The name is prefilled with the server's
|
||||
* own default so what you see is what you get; the beds and everything currently
|
||||
* planted in them come along, while the source's share link and shares do not.
|
||||
* On success we land in the copy — the point of copying is to start editing it.
|
||||
*/
|
||||
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState(() => defaultCopyName(garden.name))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
const created = await copy.mutateAsync({ id: garden.id, name: name.trim() })
|
||||
toast.info(`Copied to “${created.name}”.`)
|
||||
onClose()
|
||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: String(created.id) } })
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not copy the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Copy garden" onClose={onClose} busy={copy.isPending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Make a copy of <span className="font-medium text-fg">{garden.name}</span> with its beds and
|
||||
everything planted in them. The copy is private — the original's share link and people you've
|
||||
shared it with aren't carried over.
|
||||
</p>
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={copy.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={copy.isPending || name.trim() === ''}>
|
||||
{copy.isPending ? 'Copying…' : 'Copy garden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useDeleteGarden, type Garden } from '@/lib/gardens'
|
||||
|
||||
/** Confirmation dialog for deleting a garden (and everything in it). */
|
||||
export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const deletion = useDeleteGarden()
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Delete garden"
|
||||
confirmLabel="Delete"
|
||||
busyLabel="Deleting…"
|
||||
errorFallback="Could not delete the garden."
|
||||
onConfirm={() => deletion.mutateAsync(garden.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
|
||||
This can't be undone.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { formatDimensions } from '@/lib/units'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { useGardenFull } from '@/lib/objects'
|
||||
import { planYearOf } from '@/lib/plan'
|
||||
import { sharesQueryOptions } from '@/lib/shares'
|
||||
import { formatSize } from '@/lib/units'
|
||||
import { kindPlural } from '@/editor/kinds'
|
||||
import { GardenThumb } from './GardenThumb'
|
||||
|
||||
// The order kinds are counted in on the card's meta line.
|
||||
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
||||
|
||||
/**
|
||||
* One garden as a card: the body links into the editor. The footer differs by
|
||||
* role — the owner gets Share / Copy / Edit / Delete; a recipient sees a
|
||||
* "shared · role" badge and a Leave action (garden metadata edit, sharing and
|
||||
* copying are owner-only).
|
||||
* Ownership is the authoritative ownerId==me check, not the my_role hint.
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name + an
|
||||
* optional `plan` tag, size, a counts line, who it's shared with, and a footer
|
||||
* of Open + share / copy / edit / delete. A garden shared WITH you shows its
|
||||
* role and a leave action instead of the owner's tools.
|
||||
*/
|
||||
export function GardenCard({
|
||||
garden,
|
||||
@@ -28,47 +38,75 @@ export function GardenCard({
|
||||
onLeave: () => void
|
||||
}) {
|
||||
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||
const full = useGardenFull(garden.id)
|
||||
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||
const planYear = planYearOf(garden.name)
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const data = full.data
|
||||
if (!data) return full.isError ? 'Could not load the plot.' : '…'
|
||||
if (data.objects.length === 0) return 'Bare ground — drag your first bed on.'
|
||||
const counts = new Map<string, number>()
|
||||
for (const o of data.objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
|
||||
const parts = COUNTED_KINDS.filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!))
|
||||
for (const [k, n] of counts) if (!COUNTED_KINDS.includes(k)) parts.push(kindPlural(k, n))
|
||||
const plops = data.plantings.length
|
||||
parts.push(`${plops} ${plops === 1 ? 'planting' : 'plantings'}`)
|
||||
const since = new Date(garden.createdAt).getFullYear()
|
||||
if (Number.isFinite(since)) parts.push(`tended since ${since}`)
|
||||
return parts.join(' · ')
|
||||
}, [full.data, full.isError, garden.createdAt])
|
||||
|
||||
const sharedLine = (() => {
|
||||
if (!owner) return garden.myRole ? `Shared with you · ${garden.myRole}` : 'Shared with you'
|
||||
const list = shares.data ?? []
|
||||
if (list.length === 0) return null
|
||||
const first = list[0]
|
||||
const who = first.email.includes('@') ? first.email.slice(0, first.email.indexOf('@') + 1) : first.displayName
|
||||
return list.length === 1 ? `Shared with ${who} · ${first.role}` : `Shared with ${who} +${list.length - 1}`
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
|
||||
<div className="panel flex flex-col overflow-hidden transition-shadow hover:[box-shadow:var(--shadow-md)]">
|
||||
<Link
|
||||
to="/gardens/$gardenId"
|
||||
params={{ gardenId: String(garden.id) }}
|
||||
className="flex-1 rounded-t-xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="block border-b border-divider bg-field no-underline"
|
||||
aria-label={`Open ${garden.name}`}
|
||||
>
|
||||
<GardenThumb widthCm={garden.widthCm} heightCm={garden.heightCm} full={full.data} />
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-fg">{garden.name}</h3>
|
||||
{!owner && garden.myRole && (
|
||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
||||
shared · {garden.myRole}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
|
||||
{garden.name}
|
||||
</span>
|
||||
{planYear != null && <Tag tone="accent">plan</Tag>}
|
||||
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
|
||||
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-ink-soft">{meta}</div>
|
||||
{sharedLine && <div className="text-xs font-semibold text-accent-2-700">{sharedLine}</div>}
|
||||
<div className="mt-auto flex gap-2 pt-2">
|
||||
<Link
|
||||
to="/gardens/$gardenId"
|
||||
params={{ gardenId: String(garden.id) }}
|
||||
className="btn btn-primary flex-1 no-underline"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
{owner ? (
|
||||
<>
|
||||
<IconButton label="Share" icon="share-2" onClick={onShare} />
|
||||
<IconButton label="Copy — plan a season from it" icon="copy" onClick={onCopy} />
|
||||
<IconButton label="Edit the garden's name and size" icon="pencil" onClick={onEdit} />
|
||||
<IconButton label="Delete" icon="trash-2" onClick={onDelete} iconClassName="text-accent-700" />
|
||||
</>
|
||||
) : (
|
||||
<IconButton label="Leave this garden" icon="log-out" onClick={onLeave} iconClassName="text-accent-700" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</p>
|
||||
{garden.notes && <p className="mt-2 line-clamp-2 text-sm text-muted">{garden.notes}</p>}
|
||||
</Link>
|
||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{owner ? (
|
||||
<>
|
||||
<button type="button" onClick={onShare} className={cardActionClass}>
|
||||
Share
|
||||
</button>
|
||||
<button type="button" onClick={onCopy} className={cardActionClass}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onLeave} className={cardDangerClass}>
|
||||
Leave
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { Seg } from '@/components/ui/Seg'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import {
|
||||
cmFromFtIn,
|
||||
dimensionInputMode,
|
||||
dimensionUnitLabel,
|
||||
formatCm,
|
||||
formatDimensionInput,
|
||||
isValidDimensionCm,
|
||||
MIN_GARDEN_GRID_CM,
|
||||
parseDimension,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
|
||||
// A new plot: the design's 20′ × 12′, spoken in feet; the garden grid defaults
|
||||
// to a foot (the server's 1 m for a metric garden).
|
||||
const DEFAULT_W_FT = 20
|
||||
const DEFAULT_H_FT = 12
|
||||
const DEFAULT_GRID_CM = 100
|
||||
|
||||
const unitOptions = [
|
||||
{ value: 'imperial' as const, label: 'ft' },
|
||||
{ value: 'metric' as const, label: 'm' },
|
||||
]
|
||||
|
||||
function entryHint(unit: UnitPref): string {
|
||||
return unit === 'imperial' ? `Sizes read as feet and inches — 8' 6", 8', or 8.5 for feet.` : 'Sizes are in meters, e.g. 2.5.'
|
||||
}
|
||||
|
||||
/**
|
||||
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
|
||||
* chosen unit and stored as centimeters; switching units converts what's typed so
|
||||
* the physical size holds. A 409 rebases the form onto the server's fresh row.
|
||||
*/
|
||||
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||
const isEdit = !!garden
|
||||
const create = useCreateGarden()
|
||||
const update = useUpdateGarden()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
|
||||
const [name, setName] = useState(garden?.name ?? '')
|
||||
const [unit, setUnit] = useState<UnitPref>(initialUnit)
|
||||
const [width, setWidth] = useState(() =>
|
||||
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'),
|
||||
)
|
||||
const [height, setHeight] = useState(() =>
|
||||
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
|
||||
)
|
||||
const [gridSize, setGridSize] = useState(() =>
|
||||
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
|
||||
)
|
||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
const [more, setMore] = useState(isEdit && (!!garden.notes || garden.snapToGrid))
|
||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
function changeUnit(next: UnitPref) {
|
||||
const convert = (s: string) => {
|
||||
const cm = parseDimension(s, unit)
|
||||
return cm === null ? s : formatDimensionInput(cm, next)
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
setGridSize(convert(gridSize))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
const gridSizeCm = parseDimension(gridSize, unit)
|
||||
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
setConflict(null)
|
||||
if (!name.trim()) {
|
||||
setFormError('Give the garden a name.')
|
||||
return
|
||||
}
|
||||
const widthCm = parseDimension(width, unit)
|
||||
const heightCm = parseDimension(height, unit)
|
||||
if (widthCm === null || heightCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
}
|
||||
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
||||
setFormError('Width and depth must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
if (gridSizeCm === null || !isValidDimensionCm(gridSizeCm)) {
|
||||
setFormError('The grid must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
|
||||
try {
|
||||
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
|
||||
else await create.mutateAsync(input)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictGarden(err)
|
||||
if (current) {
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setUnit(current.unitPref)
|
||||
setWidth(formatDimensionInput(current.widthCm, current.unitPref))
|
||||
setHeight(formatDimensionInput(current.heightCm, current.unitPref))
|
||||
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
|
||||
setSnapToGrid(current.snapToGrid)
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save the garden.' : 'Could not create the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const u = dimensionUnitLabel(unit)
|
||||
const inputMode = dimensionInputMode(unit)
|
||||
|
||||
return (
|
||||
<Dialog title={isEdit ? `Edit ${garden.name}` : 'A new garden'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
<TextField label="Name" name="name" required autoFocus placeholder="Back forty" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="flex gap-2.5">
|
||||
<TextField
|
||||
label={`Width (${u})`}
|
||||
name="width"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label={`Depth (${u})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<div className="field">
|
||||
<label>Units</label>
|
||||
<Seg options={unitOptions} value={unit} onChange={changeUnit} label="Units" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-ink-mute">Stored in centimeters under the hood — {unit === 'imperial' ? 'feet are' : 'meters are'} just how you talk.</div>
|
||||
|
||||
{!more ? (
|
||||
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
||||
Grid & notes…
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
||||
<div className="flex items-end gap-2.5">
|
||||
<TextField
|
||||
label={`Garden grid (${u})`}
|
||||
name="gridSize"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
hint={
|
||||
gridTooFine && gridSizeCm !== null
|
||||
? `${formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid — plant spacing lives on each bed.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="field">
|
||||
<label>Snap objects</label>
|
||||
<Toggle on={snapToGrid} onChange={setSnapToGrid} label="Snap objects to the garden grid" />
|
||||
</div>
|
||||
</div>
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={pending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Break ground'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import {
|
||||
dimensionUnitLabel,
|
||||
formatCm,
|
||||
dimensionInputMode,
|
||||
formatDimensionInput,
|
||||
isValidDimensionCm,
|
||||
MIN_GARDEN_GRID_CM,
|
||||
parseDimension,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
|
||||
const DEFAULT_METERS = 10 // matches the server's 10 m default
|
||||
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
|
||||
|
||||
// What to say when a field can't be read. Naming the accepted forms beats
|
||||
// "invalid input", which leaves the person guessing which field and which part.
|
||||
function entryHint(unit: UnitPref): string {
|
||||
return unit === 'imperial'
|
||||
? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.`
|
||||
: 'Enter sizes in meters, e.g. 2.5.'
|
||||
}
|
||||
|
||||
const unitOptions = [
|
||||
{ value: 'metric', label: 'Metric (m)' },
|
||||
{ value: 'imperial', label: 'Imperial (ft)' },
|
||||
]
|
||||
|
||||
function dimString(cm: number | undefined, unit: UnitPref): string {
|
||||
return cm === undefined ? String(DEFAULT_METERS) : formatDimensionInput(cm, unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (no garden) or edit (garden given) form. Dimensions are entered in the
|
||||
* selected unit and converted to centimeters for the API; switching units
|
||||
* converts the current values so the physical size is preserved. A 409 rebases
|
||||
* the form onto the server's fresh row.
|
||||
*/
|
||||
export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
|
||||
const isEdit = !!garden
|
||||
const create = useCreateGarden()
|
||||
const update = useUpdateGarden()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const [name, setName] = useState(garden?.name ?? '')
|
||||
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
|
||||
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
|
||||
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
|
||||
const [gridSize, setGridSize] = useState(() =>
|
||||
formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'),
|
||||
)
|
||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
function changeUnit(next: UnitPref) {
|
||||
// Re-render each field in the new unit, preserving the physical size. An
|
||||
// unparseable field is left as typed rather than blanked.
|
||||
const convert = (s: string) => {
|
||||
const cm = parseDimension(s, unit)
|
||||
return cm === null ? s : formatDimensionInput(cm, next)
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
// The garden grid is a layout concern, so it lives at the same scale as the
|
||||
// garden's own dimensions — same helpers, same unit label. (The *bed* grid in
|
||||
// the object inspector is a plant-spacing concern and stays at cm/in.)
|
||||
setGridSize(convert(gridSize))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
// Converted once per render and used by both the submit handler and the
|
||||
// too-fine hint, so the two can never disagree about what was entered.
|
||||
const gridSizeCm = parseDimension(gridSize, unit)
|
||||
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
|
||||
// someone reaching for plant spacing, which lives on the bed instead — but it
|
||||
// is a legitimate choice for a very small garden, so the save still goes through.
|
||||
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
setConflict(null)
|
||||
|
||||
if (!name.trim()) {
|
||||
setFormError('Enter a name for the garden.')
|
||||
return
|
||||
}
|
||||
// Validate the converted centimeter values against the same bounds the
|
||||
// server enforces, so sub-cm or over-100m sizes fail here with a clear
|
||||
// message instead of a generic server error.
|
||||
const widthCm = parseDimension(width, unit)
|
||||
const heightCm = parseDimension(height, unit)
|
||||
if (widthCm === null || heightCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
}
|
||||
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
|
||||
setFormError('Width and height must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
if (gridSizeCm === null) {
|
||||
setFormError(entryHint(unit))
|
||||
return
|
||||
}
|
||||
if (!isValidDimensionCm(gridSizeCm)) {
|
||||
setFormError('Grid size must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
|
||||
const input = {
|
||||
name: name.trim(),
|
||||
widthCm,
|
||||
heightCm,
|
||||
unitPref: unit,
|
||||
notes: notes.trim(),
|
||||
gridSizeCm,
|
||||
snapToGrid,
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({ id: garden.id, ...input, version })
|
||||
} else {
|
||||
await create.mutateAsync(input)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictGarden(err)
|
||||
if (current) {
|
||||
// Someone else changed this garden: rebase the form onto the fresh row so
|
||||
// a re-save applies against the current version.
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setUnit(current.unitPref)
|
||||
setWidth(dimString(current.widthCm, current.unitPref))
|
||||
setHeight(dimString(current.heightCm, current.unitPref))
|
||||
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
|
||||
setSnapToGrid(current.snapToGrid)
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const unitLabel = dimensionUnitLabel(unit)
|
||||
const inputMode = dimensionInputMode(unit)
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
|
||||
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<Select
|
||||
label="Units"
|
||||
name="unitPref"
|
||||
value={unit}
|
||||
onChange={(e) => changeUnit(e.target.value as UnitPref)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label={`Width (${unitLabel})`}
|
||||
name="width"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Height (${unitLabel})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
required
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
label={`Garden grid (${unitLabel})`}
|
||||
name="gridSize"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snapToGrid}
|
||||
onChange={(e) => setSnapToGrid(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
Snap objects
|
||||
</label>
|
||||
</div>
|
||||
{gridTooFine && (
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
|
||||
(Bed grid in the inspector), not here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create garden'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react'
|
||||
import { localToWorld } from '@/lib/geometry'
|
||||
import type { FullGarden } from '@/lib/objects'
|
||||
import { objectStyle, rectRadius } from '@/editor/kinds'
|
||||
|
||||
/**
|
||||
* The plot thumbnail on a garden card: the field, its objects at true layout,
|
||||
* and every active planting as a dot in its plant's color. Pure SVG from the
|
||||
* editor payload — the same data the editor opens with, so the card is an
|
||||
* honest preview and the editor then loads from cache.
|
||||
*/
|
||||
export function GardenThumb({
|
||||
widthCm,
|
||||
heightCm,
|
||||
full,
|
||||
}: {
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
/** Undefined while the payload loads: draws the bare field. */
|
||||
full?: FullGarden
|
||||
}) {
|
||||
const W = Math.max(1, widthCm)
|
||||
const H = Math.max(1, heightCm)
|
||||
const sw = Math.max(2, Math.min(8, Math.round(Math.max(W, H) / 120)))
|
||||
const inset = sw
|
||||
const plantColor = useMemo(() => new Map((full?.plants ?? []).map((p) => [p.id, p.color])), [full?.plants])
|
||||
const objects = useMemo(() => [...(full?.objects ?? [])].sort((a, b) => a.zIndex - b.zIndex), [full?.objects])
|
||||
const byId = useMemo(() => new Map(objects.map((o) => [o.id, o])), [objects])
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="block h-[150px] w-full" preserveAspectRatio="xMidYMid meet" aria-hidden>
|
||||
<rect
|
||||
x={inset}
|
||||
y={inset}
|
||||
width={W - inset * 2}
|
||||
height={H - inset * 2}
|
||||
rx={Math.min(18, W * 0.03)}
|
||||
fill="var(--p-field)"
|
||||
stroke="var(--p-tree-stroke)"
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
{objects.map((o) => {
|
||||
const st = objectStyle(o)
|
||||
const t = `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
|
||||
return o.shape === 'circle' ? (
|
||||
<ellipse
|
||||
key={o.id}
|
||||
transform={t}
|
||||
rx={o.widthCm / 2}
|
||||
ry={o.heightCm / 2}
|
||||
fill={st.fill}
|
||||
stroke={st.stroke}
|
||||
strokeWidth={sw * 0.6}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
key={o.id}
|
||||
transform={t}
|
||||
x={-o.widthCm / 2}
|
||||
y={-o.heightCm / 2}
|
||||
width={o.widthCm}
|
||||
height={o.heightCm}
|
||||
rx={rectRadius(o.widthCm)}
|
||||
fill={st.fill}
|
||||
stroke={st.stroke}
|
||||
strokeWidth={sw * 0.6}
|
||||
strokeDasharray={st.dash}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{(full?.plantings ?? []).map((p) => {
|
||||
const o = byId.get(p.objectId)
|
||||
if (!o) return null
|
||||
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? '#97a97c'} />
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useRemoveShare } from '@/lib/shares'
|
||||
|
||||
/** Confirmation for a recipient leaving a garden shared with them (removes their
|
||||
* own share). */
|
||||
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const me = useMe()
|
||||
const remove = useRemoveShare(garden.id)
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Leave garden"
|
||||
confirmLabel="Leave"
|
||||
busyLabel="Leaving…"
|
||||
confirmDisabled={!me.data}
|
||||
errorFallback="Could not leave the garden."
|
||||
onConfirm={async () => {
|
||||
// The button is disabled without a current user; throw rather than
|
||||
// silently resolve (which would close the dialog as if it had worked) if
|
||||
// that guard ever drifts.
|
||||
if (!me.data) throw new Error('Not signed in.')
|
||||
await remove.mutateAsync(me.data.id)
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
|
||||
shares it with you again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import {
|
||||
useAddShare,
|
||||
useDisableShareLink,
|
||||
useEnableShareLink,
|
||||
useRemoveShare,
|
||||
useShareLink,
|
||||
useShares,
|
||||
useUpdateShareRole,
|
||||
type ShareRole,
|
||||
} from '@/lib/shares'
|
||||
|
||||
/**
|
||||
* Owner-only: invite an existing account by email (new invites start as
|
||||
* viewers — tap the role chip to flip to editor), remove a share, and manage the
|
||||
* public read-only link. v1 has no invitation emails; an unknown address gets a
|
||||
* friendly "no account with that email".
|
||||
*/
|
||||
export function ShareDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const shares = useShares(garden.id)
|
||||
const add = useAddShare(garden.id)
|
||||
const updateRole = useUpdateShareRole(garden.id)
|
||||
const remove = useRemoveShare(garden.id)
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const busy = add.isPending || updateRole.isPending || remove.isPending
|
||||
|
||||
async function onInvite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
const addr = email.trim()
|
||||
if (!addr) return
|
||||
try {
|
||||
await add.mutateAsync({ email: addr, role: 'viewer' })
|
||||
setEmail('')
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not share the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||
|
||||
return (
|
||||
<Dialog title={`Share ${garden.name}`} onClose={onClose} busy={busy} width={440}>
|
||||
<form onSubmit={onInvite} className="flex gap-2">
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
aria-label="Invite by email"
|
||||
autoComplete="off"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="primary" className="flex-none" disabled={add.isPending || !email.trim()}>
|
||||
{add.isPending ? 'Inviting…' : 'Invite'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
||||
{shares.isSuccess && shares.data.length === 0 && (
|
||||
<p className="text-[13px] text-ink-mute">Not shared with anyone yet — invites go to existing accounts.</p>
|
||||
)}
|
||||
{shares.data?.map((sh) => (
|
||||
<div key={sh.userId} className="flex items-center gap-2.5 rounded-full border border-divider bg-bg py-1.5 pl-4 pr-1.5">
|
||||
<span className="min-w-0 truncate text-[13px] font-semibold" title={`${sh.displayName} · ${sh.email}`}>
|
||||
{sh.email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="tag tag-accent-2 ml-auto cursor-pointer border-0"
|
||||
title="Tap to switch between viewer and editor"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
const role: ShareRole = sh.role === 'viewer' ? 'editor' : 'viewer'
|
||||
updateRole.mutate({ userId: sh.userId, role }, { onError: onMutationError('Could not change that role.') })
|
||||
}}
|
||||
>
|
||||
{sh.role}
|
||||
</button>
|
||||
<IconButton
|
||||
label={`Remove ${sh.displayName}`}
|
||||
icon="x"
|
||||
iconSize={13}
|
||||
variant="plain"
|
||||
size={30}
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="hr my-0.5" />
|
||||
<PublicLinkSection gardenId={garden.id} />
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** The public read-only link: a toggle, the link itself (tap to copy), and a
|
||||
* way to issue a fresh one that invalidates the old. */
|
||||
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
||||
const link = useShareLink(gardenId)
|
||||
const enable = useEnableShareLink(gardenId)
|
||||
const disable = useDisableShareLink(gardenId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const token = link.data?.enabled ? link.data.token : undefined
|
||||
const url = token ? `${window.location.origin}/g/${token}` : ''
|
||||
const busy = link.isPending || enable.isPending || disable.isPending
|
||||
|
||||
const run = (p: Promise<unknown>, fallback: string) => {
|
||||
setError(null)
|
||||
p.catch((err) => setError(errorMessage(err, fallback)))
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Clipboard may be unavailable (non-secure context); the text is selectable.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-semibold">Read-only link</span>
|
||||
<span className="text-xs text-ink-mute">anyone with it can look, no account needed</span>
|
||||
<Toggle
|
||||
className="ml-auto"
|
||||
label="Public read-only link"
|
||||
on={!!link.data?.enabled}
|
||||
disabled={busy}
|
||||
onChange={(on) =>
|
||||
on ? run(enable.mutateAsync({}), 'Could not create the link.') : run(disable.mutateAsync(), 'Could not turn off the link.')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{link.isError && <Alert>Could not load the public link.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
title="Copy the link"
|
||||
className="min-w-0 flex-1 cursor-pointer truncate rounded-full border border-dashed border-divider bg-bg px-3.5 py-2 text-left text-xs text-ink-soft hover:border-accent-400"
|
||||
>
|
||||
{copied ? 'Copied to the clipboard' : url}
|
||||
</button>
|
||||
<IconButton label="Copy the link" icon="copy" iconSize={14} onClick={copy} />
|
||||
<IconButton
|
||||
label="Issue a new link (the old one stops working)"
|
||||
icon="refresh-cw"
|
||||
iconSize={14}
|
||||
disabled={busy}
|
||||
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass } from '@/components/ui/field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import {
|
||||
useAddShare,
|
||||
useDisableShareLink,
|
||||
useEnableShareLink,
|
||||
useRemoveShare,
|
||||
useShareLink,
|
||||
useShares,
|
||||
useUpdateShareRole,
|
||||
type ShareRole,
|
||||
} from '@/lib/shares'
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'viewer', label: 'Viewer (read-only)' },
|
||||
{ value: 'editor', label: 'Editor (can edit)' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Owner-only dialog to manage a garden's shares: invite an existing user by
|
||||
* email as viewer/editor, change a share's role, or remove it. Targets existing
|
||||
* accounts only (v1 has no invitation emails) — an unknown email surfaces a
|
||||
* friendly "no account with that email".
|
||||
*/
|
||||
export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const shares = useShares(garden.id)
|
||||
const add = useAddShare(garden.id)
|
||||
const updateRole = useUpdateShareRole(garden.id)
|
||||
const remove = useRemoveShare(garden.id)
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<ShareRole>('viewer')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onInvite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (!email.trim()) {
|
||||
setError('Enter an email address.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await add.mutateAsync({ email: email.trim(), role })
|
||||
setEmail('')
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not share the garden.'))
|
||||
}
|
||||
}
|
||||
|
||||
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||
|
||||
return (
|
||||
<Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
||||
<TextField
|
||||
label="Invite by email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<Select
|
||||
label="Role"
|
||||
name="role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
||||
options={roleOptions}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={add.isPending}>
|
||||
{add.isPending ? 'Sharing…' : 'Share'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
|
||||
{shares.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
||||
{shares.isSuccess && shares.data.length === 0 && (
|
||||
<p className="text-sm text-muted">Not shared with anyone yet.</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{shares.data?.map((sh) => (
|
||||
<li key={sh.userId} className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
|
||||
<p className="truncate text-xs text-muted">{sh.email}</p>
|
||||
</div>
|
||||
<select
|
||||
value={sh.role}
|
||||
onChange={(e) => {
|
||||
setError(null)
|
||||
updateRole.mutate(
|
||||
{ userId: sh.userId, role: e.target.value as ShareRole },
|
||||
{ onError: onMutationError('Could not change that role.') },
|
||||
)
|
||||
}}
|
||||
aria-label={`Role for ${sh.displayName}`}
|
||||
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
|
||||
}}
|
||||
aria-label={`Remove ${sh.displayName}`}
|
||||
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PublicLinkSection gardenId={garden.id} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** The public read-only link controls: create, copy, regenerate, turn off. */
|
||||
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
||||
const link = useShareLink(gardenId)
|
||||
const enable = useEnableShareLink(gardenId)
|
||||
const disable = useDisableShareLink(gardenId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const token = link.data?.enabled ? link.data.token : undefined
|
||||
const url = token ? `${window.location.origin}/g/${token}` : ''
|
||||
const busy = link.isPending || enable.isPending || disable.isPending
|
||||
|
||||
const run = (p: Promise<unknown>, fallback: string) => {
|
||||
setError(null)
|
||||
p.catch((err) => setError(errorMessage(err, fallback)))
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Clipboard API may be unavailable (e.g. non-secure context); the field is
|
||||
// selectable so the user can still copy manually.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="mb-1 text-sm font-medium text-fg">Public link</h3>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
Anyone with the link can view this garden read-only — no account needed.
|
||||
</p>
|
||||
|
||||
{link.isError && <Alert>Could not load the public link.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
{link.isSuccess && !link.data.enabled && (
|
||||
<Button onClick={() => run(enable.mutateAsync({}), 'Could not create the link.')} disabled={busy}>
|
||||
{enable.isPending ? 'Creating…' : 'Create public link'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{link.isSuccess && link.data.enabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
aria-label="Public link URL"
|
||||
className={cn(fieldControlClass, 'min-w-0 flex-1 text-sm')}
|
||||
/>
|
||||
<Button variant="ghost" onClick={copy} disabled={!url}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
||||
disabled={busy}
|
||||
title="Issue a new link and invalidate the old one"
|
||||
>
|
||||
{enable.isPending ? 'Working…' : 'Regenerate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||
onClick={() => run(disable.mutateAsync(), 'Could not turn off the link.')}
|
||||
disabled={busy}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { useLogout, type User } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** The avatar circle in the nav (the user's initial on sage) and its small
|
||||
* sign-out popover. */
|
||||
export function AccountMenu({ user, className }: { user: User; className?: string }) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
// Close on any route change so navigating can't leave the popover stuck open.
|
||||
useEffect(() => setOpen(false), [pathname])
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
} catch {
|
||||
// Keep the popover open so "Retry sign out" stays reachable.
|
||||
}
|
||||
}
|
||||
|
||||
const initial = user.displayName.trim().charAt(0).toUpperCase() || '·'
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`Account: ${user.displayName}`}
|
||||
title={user.displayName}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-accent-2-300 text-sm font-bold text-accent-2-800"
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<button type="button" aria-label="Close menu" className="fixed inset-0 z-30 cursor-default" onClick={() => setOpen(false)} />
|
||||
<div role="menu" className="panel elev-md absolute right-0 z-40 mt-2 w-56 !rounded-[18px] p-2">
|
||||
<p className="truncate px-3 py-2 text-xs text-ink-mute">
|
||||
Signed in as <span className="font-semibold text-text">{user.displayName}</span>
|
||||
<br />
|
||||
<span className="text-[11px]">{user.email}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
className="btn btn-ghost w-full justify-start gap-2 px-3 text-[13px]"
|
||||
>
|
||||
<Icon name="log-out" size={14} />
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,236 +1,24 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Suspense } from 'react'
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { useLogout, useMe } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Top-level sections. `icon` is only used by the mobile bottom bar; the desktop
|
||||
// top bar shows labels alone. Settings is filtered to admins at render.
|
||||
const sections = [
|
||||
{ to: '/gardens', label: 'Gardens', icon: '🏡', adminOnly: false },
|
||||
{ to: '/plants', label: 'Plants', icon: '🌿', adminOnly: false },
|
||||
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
|
||||
] as const
|
||||
|
||||
type Section = (typeof sections)[number]
|
||||
|
||||
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
||||
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
||||
// state props — never in the base — to avoid ambiguous overrides.
|
||||
const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors'
|
||||
const navLinkActive = 'bg-border/60 text-fg'
|
||||
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
||||
|
||||
/**
|
||||
* Top-level chrome. Mobile-first: a slim top bar (brand + account) with the
|
||||
* section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint
|
||||
* (`md:`) restores the inline top nav. The editor is a full-screen context, so it
|
||||
* owns the bottom of the screen — the app bottom bar hides there (the brand link
|
||||
* is the way back to the gardens list), leaving exactly one bottom bar per route.
|
||||
* The root layout is deliberately empty chrome: every page renders its own nav
|
||||
* (the editor wants a different one on a phone than Gardens does), so the shell
|
||||
* only provides the Suspense boundary for the code-split routes and the toast
|
||||
* stack. The theme is applied to <html> by the bootstrap in index.html.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const me = useMe()
|
||||
const user = me.data
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
// Full-screen canvas contexts own the bottom of the screen: the garden editor
|
||||
// and the public shared-garden view both render a 100dvh-8rem canvas, so a
|
||||
// signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false
|
||||
// so the '/gardens' list itself still shows the bar.
|
||||
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
|
||||
const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
|
||||
// A canvas route wants the whole width — the garden editor is squeezed by the
|
||||
// max-w-5xl reading measure the other pages use (#107).
|
||||
const canvasRoute = inEditor || inPublicGarden
|
||||
const showBottomNav = !!user && !canvasRoute
|
||||
// On a phone the editor is a full-screen canvas, so the global top bar is pure
|
||||
// chrome above the garden — hide it and let the editor's own strip carry the
|
||||
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
|
||||
// public view, which has no strip of its own to fall back on.
|
||||
const hideHeaderOnMobile = inEditor
|
||||
|
||||
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
|
||||
hideHeaderOnMobile && 'hidden md:block',
|
||||
)}
|
||||
>
|
||||
{/* The bar matches the content width below: constrained on reading pages,
|
||||
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
|
||||
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
|
||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||
🌱 pansy
|
||||
</Link>
|
||||
|
||||
{/* Desktop inline section links. Hidden on mobile, where the bottom bar
|
||||
carries them. */}
|
||||
<div className="hidden flex-1 items-center gap-1 md:flex">
|
||||
{user &&
|
||||
visibleSections.map((s) => (
|
||||
<Link
|
||||
key={s.to}
|
||||
to={s.to}
|
||||
className={navLinkBase}
|
||||
activeProps={{ className: navLinkActive }}
|
||||
inactiveProps={{ className: navLinkInactive }}
|
||||
>
|
||||
{s.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Spacer so account/sign-out sits right on mobile (the desktop links
|
||||
own flex-1 above). */}
|
||||
<div className="flex-1 md:hidden" />
|
||||
|
||||
{user ? (
|
||||
<AccountMenu displayName={user.displayName} />
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'w-full flex-1 px-4 py-6',
|
||||
// Constrain the reading pages to a comfortable measure; the canvas
|
||||
// routes go edge-to-edge so the garden gets the whole screen.
|
||||
!canvasRoute && 'mx-auto max-w-5xl',
|
||||
// Clear the fixed bottom bar on mobile so content isn't hidden behind
|
||||
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
|
||||
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
|
||||
)}
|
||||
>
|
||||
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
|
||||
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading…</p>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{showBottomNav && <BottomNav sections={visibleSections} />}
|
||||
|
||||
<>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Account control: a compact button that toggles a small sign-out popover. On
|
||||
* desktop the display name shows inline; on mobile it lives inside the popover.
|
||||
* Exported so the editor's mobile strip can carry it — the global header that
|
||||
* normally hosts it is hidden there (see hideHeaderOnMobile). */
|
||||
export function AccountMenu({ displayName }: { displayName: string }) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
// Close on any route change, so navigating (bottom nav, browser back) can't
|
||||
// leave the popover — and its full-screen backdrop — stuck open over the page.
|
||||
useEffect(() => setOpen(false), [pathname])
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await logout.mutateAsync()
|
||||
await navigate({ to: '/login' })
|
||||
} catch {
|
||||
// The logout request failed, so the session is still valid server-side.
|
||||
// Keep the popover OPEN so the button (now "Retry sign out", driven by
|
||||
// logout.isError) stays on screen — closing it would hide the only retry
|
||||
// affordance and pretend nothing went wrong.
|
||||
}
|
||||
}
|
||||
|
||||
const initial = displayName.trim().charAt(0).toUpperCase() || '·'
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
<span className="hidden sm:inline">{displayName}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 items-center justify-center rounded-full bg-border/60 text-sm font-semibold text-fg"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* Full-screen click-away backdrop; reliably closes on an outside tap
|
||||
without a document listener. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-30 cursor-default"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-40 mt-2 w-48 rounded-lg border border-border bg-surface p-1 shadow-lg"
|
||||
>
|
||||
<p className="truncate px-3 py-2 text-xs text-muted">
|
||||
Signed in as <span className="text-fg">{displayName}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onLogout}
|
||||
disabled={logout.isPending}
|
||||
title={logout.isError ? 'Sign out failed — try again' : undefined}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg disabled:opacity-60"
|
||||
>
|
||||
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
|
||||
function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
>
|
||||
<ul className="mx-auto flex max-w-5xl items-stretch justify-around">
|
||||
{sections.map((s) => (
|
||||
<li key={s.to} className="flex-1">
|
||||
{/* h-14 matches the clearance reserved on <main> above. Color lives
|
||||
only in the state props (per the top-bar convention), never the
|
||||
base, so active/inactive don't fight. */}
|
||||
<Link
|
||||
to={s.to}
|
||||
className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
|
||||
activeProps={{ className: 'text-accent-strong' }}
|
||||
inactiveProps={{ className: 'text-muted hover:text-fg' }}
|
||||
>
|
||||
<span aria-hidden className="text-lg leading-none">
|
||||
{s.icon}
|
||||
</span>
|
||||
{s.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
export function PageFallback({ label = 'Loading…' }: { label?: string }) {
|
||||
return <p className="p-7 text-[13px] font-semibold text-ink-mute">{label}</p>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { AccountMenu } from './AccountMenu'
|
||||
import { ThemeButton } from './ThemeButton'
|
||||
|
||||
export type NavSection = 'gardens' | 'plants' | 'settings'
|
||||
|
||||
/** The brand mark: sage sprout + "pansy" in the display face. */
|
||||
export function Brand({ size = 19, textClassName }: { size?: number; textClassName?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 font-heading text-lg text-text">
|
||||
<Icon name="sprout" size={size} className="text-accent-2-600" />
|
||||
<span className={textClassName}>pansy</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The top nav shared by the Gardens, Plants and Settings pages (and the editor
|
||||
* on desktop): brand, centered section links, and the right cluster — theme
|
||||
* toggle, the settings gear for admins, the account avatar.
|
||||
*/
|
||||
export function Nav({ active }: { active?: NavSection }) {
|
||||
const me = useMe()
|
||||
const user = me.data
|
||||
const link = (to: '/gardens' | '/plants', id: NavSection, label: string) => (
|
||||
<Link
|
||||
to={to}
|
||||
aria-current={active === id ? 'page' : undefined}
|
||||
className={cn('text-sm text-text no-underline hover:text-accent', active === id && 'text-accent')}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
return (
|
||||
<nav className="flex flex-none items-center gap-[17.6px] px-[17.6px] py-[13.2px]">
|
||||
<Link to="/gardens" className="mr-auto no-underline">
|
||||
<Brand />
|
||||
</Link>
|
||||
{link('/gardens', 'gardens', 'Gardens')}
|
||||
{link('/plants', 'plants', 'Plants')}
|
||||
<span className="ml-auto flex items-center gap-2.5">
|
||||
<ThemeButton />
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/settings"
|
||||
aria-current={active === 'settings' ? 'page' : undefined}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
className={cn('btn btn-icon btn-secondary', active === 'settings' && 'bg-accent-100 text-accent')}
|
||||
>
|
||||
<Icon name="settings" />
|
||||
</Link>
|
||||
)}
|
||||
{user && <AccountMenu user={user} />}
|
||||
</span>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import type { IconName } from '@/components/ui/Icon'
|
||||
import { cycleThemePref, useThemePref, type ThemePref } from '@/lib/theme'
|
||||
|
||||
const ICON: Record<ThemePref, IconName> = { system: 'monitor', light: 'sun', dark: 'moon' }
|
||||
|
||||
/** The nav's theme control: one button cycling system → light → dark. */
|
||||
export function ThemeButton({ size, iconSize, className }: { size?: number; iconSize?: number; className?: string }) {
|
||||
const pref = useThemePref()
|
||||
return (
|
||||
<IconButton
|
||||
label={`Theme: ${pref}`}
|
||||
icon={ICON[pref]}
|
||||
iconSize={iconSize}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={cycleThemePref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { CATEGORY_LABELS, PLANT_CATEGORIES, type CategoryFilter } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Horizontal, scrollable "All + each category" chip row. Shared by the /plants
|
||||
* page and the PlantPicker so both filter the catalog identically.
|
||||
*/
|
||||
export function CategoryChips({
|
||||
value,
|
||||
onChange,
|
||||
size = 'md',
|
||||
}: {
|
||||
value: CategoryFilter
|
||||
onChange: (c: CategoryFilter) => void
|
||||
size?: 'sm' | 'md'
|
||||
}) {
|
||||
const chip = (v: CategoryFilter, label: string) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onChange(v)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-3 font-medium transition-colors',
|
||||
size === 'sm' ? 'py-1 text-xs' : 'py-1 text-sm',
|
||||
value === v ? 'bg-accent text-accent-contrast' : 'bg-border/50 text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<div className="flex gap-1.5 overflow-x-auto">
|
||||
{chip('all', 'All')}
|
||||
{PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Six curated marker colors from the design palette; the seventh well is a
|
||||
// native color input for anything else.
|
||||
export const CURATED_SWATCHES = ['#97a97c', '#c8553d', '#5f8f45', '#d9912f', '#b2622d', '#6d7f5a']
|
||||
|
||||
/** Expand #rgb to #rrggbb so the native color input renders it. */
|
||||
export function expandHex(color: string, fallback = CURATED_SWATCHES[0]): string {
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
||||
const [, r, g, b] = color
|
||||
return `#${r}${r}${g}${g}${b}${b}`
|
||||
}
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback
|
||||
}
|
||||
|
||||
export function ColorSwatches({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
|
||||
const custom = !CURATED_SWATCHES.includes(value.toLowerCase())
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{CURATED_SWATCHES.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
type="button"
|
||||
title={hex}
|
||||
aria-label={`Marker color ${hex}`}
|
||||
aria-pressed={value.toLowerCase() === hex}
|
||||
onClick={() => onChange(hex)}
|
||||
className={cn('h-[30px] w-[30px] rounded-full border-[3px]', value.toLowerCase() === hex ? 'border-accent' : 'border-transparent')}
|
||||
style={{ background: hex }}
|
||||
/>
|
||||
))}
|
||||
<label
|
||||
title="Any other color"
|
||||
className={cn(
|
||||
'relative grid h-[30px] w-[30px] cursor-pointer place-items-center overflow-hidden rounded-full border-[3px]',
|
||||
custom ? 'border-accent' : 'border-transparent',
|
||||
)}
|
||||
style={{ background: custom ? value : 'conic-gradient(#c8553d, #d9912f, #97a97c, #5f8f45, #6d7f5a, #b2622d, #c8553d)' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Custom marker color"
|
||||
value={expandHex(value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useDeletePlant, type Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Confirmation dialog for deleting a custom plant. A plant still used by
|
||||
* plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
|
||||
* that message inline rather than pretending it worked.
|
||||
*/
|
||||
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
|
||||
const deletion = useDeletePlant()
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Delete plant"
|
||||
confirmLabel="Delete"
|
||||
busyLabel="Deleting…"
|
||||
errorFallback="Could not delete the plant."
|
||||
onConfirm={() => deletion.mutateAsync(plant.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
|
||||
undone.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||
|
||||
/**
|
||||
* Retire a lot. Worth confirming because it's the one place cost and germination
|
||||
* data lives — and worth saying plainly that the plantings survive it, since
|
||||
* "will this wipe my garden" is the reasonable fear.
|
||||
*/
|
||||
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
||||
const del = useDeleteSeedLot()
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Retire this seed lot?"
|
||||
confirmLabel="Retire lot"
|
||||
busyLabel="Retiring…"
|
||||
errorFallback="Could not retire the lot."
|
||||
onConfirm={() => del.mutateAsync(lot.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** A plant marker off the canvas: a solid circle in the plant's color with its
|
||||
* 1–2 letter monogram in the display face. Size in px. */
|
||||
export function Monogram({
|
||||
color,
|
||||
letters,
|
||||
size = 40,
|
||||
className,
|
||||
}: {
|
||||
color: string
|
||||
letters: string
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none text-paper', className)}
|
||||
style={{ width: size, height: size, background: color, fontSize: Math.round(size * 0.425) }}
|
||||
>
|
||||
{letters}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The small color dot used in lists and rosters. */
|
||||
export function ColorDot({ color, size = 14, className }: { color: string; size?: number; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('inline-block flex-none rounded-full', className)}
|
||||
style={{ width: size, height: size, background: color }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { PlantIcon } from './PlantIcon'
|
||||
import { LotStateChip, SeedLotList } from './SeedLotList'
|
||||
import { SourceLink } from './SourceLink'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatCost, formatQuantity, lotState, safeExternalUrl, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
import { Monogram } from './Monogram'
|
||||
|
||||
/**
|
||||
* One catalog plant as a card: icon tile tinted with the plant's color, name,
|
||||
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
|
||||
* offer only "Duplicate" (they're read-only); own plants add Edit/Delete.
|
||||
* One catalog plant as a card: monogram, name, "Category · spacing · days", a
|
||||
* built-in tag for seeded plants, and a seed-lot summary. Clicking expands it
|
||||
* (accent border) to the lot cards — vendor, packed-for year, what's left — and
|
||||
* the plant's own actions. Built-ins are read-only: duplicate to customize.
|
||||
*/
|
||||
export function PlantCard({
|
||||
plant,
|
||||
letters,
|
||||
unit,
|
||||
lots,
|
||||
onEdit,
|
||||
@@ -24,9 +27,8 @@ export function PlantCard({
|
||||
onDeleteLot,
|
||||
}: {
|
||||
plant: Plant
|
||||
letters: string
|
||||
unit: UnitPref
|
||||
/** This plant's purchases. A lot may reference a built-in, so even a built-in
|
||||
* card can carry seed. */
|
||||
lots: SeedLot[]
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
@@ -36,80 +38,127 @@ export function PlantCard({
|
||||
onDeleteLot: (lot: SeedLot) => void
|
||||
}) {
|
||||
const builtin = isBuiltin(plant)
|
||||
const [showLots, setShowLots] = useState(false)
|
||||
const summary = summarizeLots(lots)
|
||||
const [open, setOpen] = useState(false)
|
||||
const sub = [CATEGORY_LABELS[plant.category], `${formatSpacing(plant.spacingCm, unit)} spacing`]
|
||||
if (plant.daysToMaturity != null) sub.push(`${plant.daysToMaturity} days`)
|
||||
const lotText =
|
||||
lots.length === 0
|
||||
? `No seed lots · click to ${open ? 'close' : 'expand'}`
|
||||
: `${lots.length} seed ${lots.length === 1 ? 'lot' : 'lots'} · click to ${open ? 'close' : 'see'}`
|
||||
const source = safeExternalUrl(plant.sourceUrl)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-11 w-11 rounded-lg text-2xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-fg">{plant.name}</h3>
|
||||
{builtin && (
|
||||
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
|
||||
Built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
||||
</p>
|
||||
{(plant.vendor || plant.sourceUrl) && (
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||
{plant.vendor && <span>{plant.vendor}</span>}
|
||||
<SourceLink url={plant.sourceUrl} />
|
||||
</p>
|
||||
)}
|
||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
||||
</div>
|
||||
<span
|
||||
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-black/10 dark:border-white/10"
|
||||
style={{ backgroundColor: plant.color }}
|
||||
title={plant.color}
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
setOpen((v) => !v)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'panel relative cursor-pointer px-[18px] py-4 transition-shadow hover:[box-shadow:var(--shadow-md)]',
|
||||
open && 'border-accent-400',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Monogram color={plant.color} letters={letters} />
|
||||
<span className="flex min-w-0 flex-col gap-px">
|
||||
<span className="truncate font-heading text-[16.5px]" title={plant.name}>
|
||||
{plant.name}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-ink-mute">{sub.join(' · ')}</span>
|
||||
</span>
|
||||
{builtin && <Tag tone="neutral" className="ml-auto flex-none">built-in</Tag>}
|
||||
</div>
|
||||
{showLots && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
|
||||
<div className="mt-2.5 text-[12.5px] text-ink-soft">{lotText}</div>
|
||||
|
||||
{open && (
|
||||
// Stop clicks inside the expanded area from toggling the card.
|
||||
<div className="mt-2.5 flex flex-col gap-2" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
|
||||
{lots.map((lot) => (
|
||||
<LotCard key={lot.id} lot={lot} onEdit={() => onEditLot(lot)} onDelete={() => onDeleteLot(lot)} />
|
||||
))}
|
||||
{lots.length === 0 && (
|
||||
<div className="text-[12.5px] text-ink-mute">No seed lots yet — scan a packet, or record one by hand.</div>
|
||||
)}
|
||||
{(plant.vendor || source || plant.notes) && (
|
||||
<div className="text-xs leading-relaxed text-ink-soft">
|
||||
{plant.vendor && <span>{plant.vendor}</span>}
|
||||
{plant.vendor && source && <span> · </span>}
|
||||
{source && (
|
||||
<a href={source} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1">
|
||||
source <Icon name="external-link" size={11} />
|
||||
</a>
|
||||
)}
|
||||
{plant.notes && <p className="mt-1 whitespace-pre-wrap">{plant.notes}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
|
||||
Record a lot
|
||||
</Button>
|
||||
<span className="ml-auto flex gap-1">
|
||||
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
|
||||
Duplicate
|
||||
</Button>
|
||||
{!builtin && (
|
||||
<Button variant="ghost" icon="pencil" iconSize={12} className="px-2.5 text-[13px]" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{!builtin && (
|
||||
<Button variant="ghost" icon="trash-2" iconSize={12} className="px-2.5 text-[13px] text-accent-700" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{/* The seed count sits with the actions rather than in the body: it's
|
||||
what you scan for down a list of twenty packets, so it wants a fixed
|
||||
place on the card. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLots((v) => !v)}
|
||||
className={`${cardActionClass} mr-auto gap-1.5`}
|
||||
aria-expanded={showLots}
|
||||
>
|
||||
{lots.length === 0 ? (
|
||||
<span className="text-muted">No seed</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="tabular-nums">
|
||||
{formatQuantity(summary.remaining)}
|
||||
{summary.unit ? ` ${summary.unit}` : ''} left
|
||||
</span>
|
||||
<LotStateChip state={summary.state} />
|
||||
</>
|
||||
)}
|
||||
/** What's left of a lot, said plainly: "50 cloves · 14 left" — or how far over
|
||||
* it was planted, which is a real situation worth showing rather than clamping. */
|
||||
export function describeLot(lot: SeedLot): string {
|
||||
const parts = [`${formatQuantity(lot.quantity)} ${lot.unit}`]
|
||||
const state = lotState(lot)
|
||||
if (state === 'over') parts.push(`${formatQuantity(-lot.remaining)} over what was bought`)
|
||||
else if (state === 'empty') parts.push('none left')
|
||||
else if (state !== 'unknown') parts.push(`${formatQuantity(lot.remaining)} left`)
|
||||
if (lot.germinationPct != null) parts.push(`${lot.germinationPct}% germination`)
|
||||
const cost = formatCost(lot.costCents)
|
||||
if (cost) parts.push(cost)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function LotCard({ lot, onEdit, onDelete }: { lot: SeedLot; onEdit: () => void; onDelete: () => void }) {
|
||||
const state = lotState(lot)
|
||||
return (
|
||||
<div className="rounded-md border border-divider bg-bg px-[13px] py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-[12.5px] font-bold">{lot.vendor || 'Unnamed lot'}</span>
|
||||
{state === 'low' && <Tag tone="accent">low</Tag>}
|
||||
{state === 'empty' && <Tag tone="neutral">empty</Tag>}
|
||||
{state === 'over' && <Tag tone="accent">over-planted</Tag>}
|
||||
<span className="ml-auto flex-none text-[11.5px] text-ink-mute">
|
||||
{lot.packedForYear != null ? `packed for ${lot.packedForYear}` : lot.purchasedAt ? `bought ${lot.purchasedAt}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-[3px] text-xs text-ink-soft">{describeLot(lot)}</div>
|
||||
{lot.notes && <div className="mt-1 text-xs text-ink-mute">{lot.notes}</div>}
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
<button type="button" className="btn btn-ghost px-2 py-1 text-xs" onClick={onEdit}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||
Duplicate
|
||||
<button type="button" className="btn btn-ghost px-2 py-1 text-xs text-accent-700" onClick={onDelete}>
|
||||
Retire
|
||||
</button>
|
||||
{!builtin && (
|
||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{!builtin && (
|
||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
|
||||
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
|
||||
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
|
||||
* `rounded` is false when a caller (the tray) attaches a trailing control and
|
||||
* needs a flat right edge.
|
||||
*/
|
||||
export function PlantChip({
|
||||
plant,
|
||||
active,
|
||||
onArm,
|
||||
rounded = true,
|
||||
}: {
|
||||
plant: Plant
|
||||
active: boolean
|
||||
onArm: (plant: Plant) => void
|
||||
rounded?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArm(plant)}
|
||||
aria-pressed={active}
|
||||
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong'
|
||||
: 'border-border bg-surface text-fg hover:border-accent',
|
||||
)}
|
||||
>
|
||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
|
||||
<span className="max-w-[6rem] truncate">{plant.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
+60
-107
@@ -1,10 +1,8 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
@@ -18,28 +16,20 @@ import {
|
||||
} from '@/lib/plants'
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
|
||||
|
||||
const DEFAULT_COLOR = '#4a7c3f'
|
||||
// Markers are monograms now, but the API still carries an icon per plant; a
|
||||
// plant made here gets the neutral sprout so nothing downstream sees an empty one.
|
||||
const DEFAULT_ICON = '🌱'
|
||||
|
||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||
|
||||
/** Expand a #rgb shorthand to #rrggbb so the native color input renders it. */
|
||||
function expandHex(color: string): string {
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(color)) {
|
||||
const [, r, g, b] = color
|
||||
return `#${r}${r}${g}${g}${b}${b}`
|
||||
}
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : DEFAULT_COLOR
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or edit a custom plant. `plant` puts it in edit mode; `template` (a
|
||||
* built-in or another plant, used by "Duplicate") pre-fills a fresh create. A
|
||||
* 409 rebases the form onto the server's current row. Spacing is entered in the
|
||||
* page's unit and converted to centimeters for the API.
|
||||
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
|
||||
* (`template`, the Duplicate action; the only way to customize a built-in).
|
||||
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases
|
||||
* onto the server's current row.
|
||||
*/
|
||||
export function PlantFormModal({
|
||||
export function PlantDialog({
|
||||
plant,
|
||||
template,
|
||||
unit,
|
||||
@@ -59,12 +49,12 @@ export function PlantFormModal({
|
||||
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
|
||||
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
|
||||
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
|
||||
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
||||
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
||||
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
|
||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||
const [notes, setNotes] = useState(source?.notes ?? '')
|
||||
const [more, setMore] = useState(!!(source?.vendor || source?.sourceUrl || source?.notes))
|
||||
const [version, setVersion] = useState(plant?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
@@ -74,13 +64,8 @@ export function PlantFormModal({
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
setConflict(null)
|
||||
|
||||
if (!name.trim()) {
|
||||
setFormError('Enter a name for the plant.')
|
||||
return
|
||||
}
|
||||
if (!icon.trim()) {
|
||||
setFormError('Pick an emoji icon.')
|
||||
setFormError('Give the plant a name.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
@@ -90,76 +75,64 @@ export function PlantFormModal({
|
||||
}
|
||||
let daysToMaturity: number | null = null
|
||||
if (days.trim()) {
|
||||
// Number() (not parseInt) so "1.5" is rejected, not silently truncated to 1.
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) {
|
||||
setFormError('Days to maturity must be a whole number of days, or left blank.')
|
||||
setFormError('Days to maturity is a whole number of days, or blank.')
|
||||
return
|
||||
}
|
||||
daysToMaturity = d
|
||||
}
|
||||
|
||||
// The server refuses anything that isn't http(s) with a host, but say so here
|
||||
// rather than letting a paste of "johnnyseeds.com" come back as a generic
|
||||
// error with no hint about which field or why.
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||
setFormError('The source link needs to be a full http:// or https:// address.')
|
||||
return
|
||||
}
|
||||
|
||||
const input: PlantInput = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
spacingCm,
|
||||
color,
|
||||
icon: icon.trim(),
|
||||
icon: source?.icon || DEFAULT_ICON,
|
||||
daysToMaturity,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
vendor: vendor.trim(),
|
||||
notes: notes.trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({ id: plant.id, ...input, version })
|
||||
} else {
|
||||
await create.mutateAsync(input)
|
||||
}
|
||||
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
|
||||
else await create.mutateAsync(input)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictPlant(err)
|
||||
if (current) {
|
||||
// Someone else changed this plant: rebase onto the fresh row so a re-save
|
||||
// applies against the current version.
|
||||
setVersion(current.version)
|
||||
setName(current.name)
|
||||
setCategory(current.category)
|
||||
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
|
||||
setColor(expandHex(current.color))
|
||||
setIcon(current.icon)
|
||||
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
|
||||
setVendor(current.vendor)
|
||||
setSourceUrl(current.sourceUrl)
|
||||
setNotes(current.notes)
|
||||
setConflict('This plant changed elsewhere. The latest values are shown — review and save again.')
|
||||
setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.'))
|
||||
setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit plant' : 'New plant'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
<Dialog title={isEdit ? `Edit ${plant.name}` : 'A new plant'} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
|
||||
<TextField label="Name" name="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
<TextField label="Name" name="name" required autoFocus placeholder="Delicata squash" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="flex gap-2.5">
|
||||
<SelectField
|
||||
label="Category"
|
||||
name="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||
options={categoryOptions}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
<TextField
|
||||
label={`Spacing (${unitLabel})`}
|
||||
@@ -171,31 +144,12 @@ export function PlantFormModal({
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="plant-color" className="text-sm font-medium text-fg">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="plant-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-10 w-full cursor-pointer rounded-md border border-border bg-surface"
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Icon (emoji)"
|
||||
name="icon"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
hint="A single emoji, e.g. 🍅"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field label="Marker color">
|
||||
<ColorSwatches value={color} onChange={setColor} />
|
||||
</Field>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
@@ -207,40 +161,39 @@ export function PlantFormModal({
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Provenance for the variety itself. What you bought and what's left
|
||||
of it is a seed lot, added from the plant card. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Vendor"
|
||||
name="vendor"
|
||||
placeholder="Johnny's Selected Seeds"
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Source link"
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://…"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
{!more ? (
|
||||
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
|
||||
Vendor, source link & notes…
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
|
||||
<div className="flex gap-2.5">
|
||||
<TextField label="Vendor" name="vendor" placeholder="Johnny's" value={vendor} onChange={(e) => setVendor(e.target.value)} wrapperClassName="flex-1" />
|
||||
<TextField
|
||||
label="Source link"
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://…"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
wrapperClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={pending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Create plant'}
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Add it'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* A plant's emoji on a tile tinted with its own color (via color-mix, so any
|
||||
* valid CSS color works). Shared by PlantCard and the PlantPicker rows. Size and
|
||||
* shape come from `className`.
|
||||
*/
|
||||
export function PlantIcon({ color, icon, className }: { color: string; icon: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('grid shrink-0 place-items-center', className)}
|
||||
style={{ backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }}
|
||||
aria-hidden
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useRef, useState, type ChangeEvent, type DragEvent, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { SelectField, TextField } from '@/components/ui/Field'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { CATEGORY_LABELS, PLANT_CATEGORIES, isBuiltin, type PlantCategory, type PlantInput } from '@/lib/plants'
|
||||
import { lotDefaults, newPlantDefaults, useCreateFromPacket, useScanPacket, type PacketProposal } from '@/lib/seedPacket'
|
||||
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
|
||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||
|
||||
// 'new' is "create a new plant"; a number selects that existing candidate.
|
||||
type Selection = number | 'new'
|
||||
|
||||
/**
|
||||
* Photograph a seed packet → the vision model reads it into fields → the user
|
||||
* matches it to the catalog and confirms (#81/#102). Two steps in one dialog.
|
||||
* The read never writes; a misread can't add anything on its own, and a wrong
|
||||
* auto-match would split a variety's history across duplicate rows — so the
|
||||
* match is always a human choice. Only offered where `capabilities.vision` is
|
||||
* on; a 503 is still handled in case the model is torn down in between.
|
||||
*/
|
||||
export function ScanPacketDialog({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||
const scan = useScanPacket()
|
||||
const create = useCreateFromPacket()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const scanAbort = useRef<AbortController | null>(null)
|
||||
|
||||
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const [selection, setSelection] = useState<Selection>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||
const [spacing, setSpacing] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [vendor, setVendor] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||
const [packedForYear, setPackedForYear] = useState('')
|
||||
|
||||
const unitLabel = spacingUnitLabel(unit)
|
||||
const busy = scan.isPending || create.isPending
|
||||
|
||||
function readFile(file: File | undefined) {
|
||||
if (!file) return
|
||||
setError(null)
|
||||
const controller = new AbortController()
|
||||
scanAbort.current = controller
|
||||
scan.mutate(
|
||||
{ file, signal: controller.signal },
|
||||
{
|
||||
onSuccess: (p) => {
|
||||
const plant = newPlantDefaults(p)
|
||||
const lot = lotDefaults(p.packet)
|
||||
setProposal(p)
|
||||
setSelection(p.candidates[0]?.plant.id ?? 'new')
|
||||
setName(plant.name)
|
||||
setCategory(plant.category)
|
||||
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
|
||||
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
|
||||
setVendor(lot.vendor)
|
||||
setQuantity(String(lot.quantity))
|
||||
setLotUnit(lot.unit)
|
||||
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
},
|
||||
onError: (err) => {
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = '' // so re-picking the same file fires change again
|
||||
readFile(file)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
readFile(e.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!proposal) return
|
||||
setError(null)
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
|
||||
year = y
|
||||
}
|
||||
const lot = {
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: '',
|
||||
sku: proposal.packet.sku,
|
||||
lotCode: proposal.packet.lotCode,
|
||||
purchasedAt: null,
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit: lotUnit,
|
||||
costCents: null,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
let newPlant: PlantInput | undefined
|
||||
let plantId: number | undefined
|
||||
if (selection === 'new') {
|
||||
if (!name.trim()) return setError('Name the new plant, or pick an existing one above.')
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) return setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||
let daysToMaturity: number | null = null
|
||||
if (days.trim()) {
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) return setError('Days to maturity is a whole number of days, or blank.')
|
||||
daysToMaturity = d
|
||||
}
|
||||
newPlant = { ...newPlantDefaults(proposal), name: name.trim(), category, spacingCm, daysToMaturity, vendor: vendor.trim() }
|
||||
} else {
|
||||
plantId = selection
|
||||
}
|
||||
try {
|
||||
const res = await create.mutateAsync({ plantId, newPlant, lot })
|
||||
toast.info(res.plantIsNew ? `Added ${res.plant.name} and its seed lot.` : `Recorded a seed lot for ${res.plant.name}.`)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not save the packet.'))
|
||||
}
|
||||
}
|
||||
|
||||
const variety = proposal ? [proposal.packet.species, proposal.packet.variety].filter(Boolean).join(' — ') : ''
|
||||
|
||||
return (
|
||||
<Dialog title="Scan a seed packet" onClose={onClose} busy={create.isPending} width={560}>
|
||||
{!proposal ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2.5 rounded-lg border-2 border-dashed px-5 py-9 text-center',
|
||||
dragOver ? 'border-accent-400 bg-accent-100' : 'border-neutral-400',
|
||||
)}
|
||||
>
|
||||
<Icon name="camera" size={30} className="text-accent-2-600" />
|
||||
<div className="text-sm font-semibold">Photograph the packet — front is enough</div>
|
||||
<div className="max-w-[36ch] text-[12.5px] text-ink-mute">
|
||||
The vision model reads it into fields. It only reads; nothing is saved until you confirm.
|
||||
</div>
|
||||
{scan.isPending ? (
|
||||
<p className="mt-1 flex items-center gap-2 text-[13px] font-semibold text-ink-soft">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
Reading the packet… this can take a few seconds.
|
||||
</p>
|
||||
) : (
|
||||
<Button variant="primary" icon="camera" className="mt-1" onClick={() => fileInput.current?.click()}>
|
||||
Take or choose a photo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end">
|
||||
{/* Never disabled — this is the way out of a slow scan. */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
scanAbort.current?.abort()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={onConfirm} className="flex flex-col gap-3.5">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TextField label="Variety" name="variety" value={selection === 'new' ? name : variety} readOnly={selection !== 'new'} onChange={(e) => setName(e.target.value)} />
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
|
||||
<div className="field">
|
||||
<label htmlFor="scan-quantity">Quantity</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input id="scan-quantity" className="input min-w-0" type="number" inputMode="decimal" step="any" min="0" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
<select className="input w-auto flex-none" aria-label="Unit" value={lotUnit} onChange={(e) => setLotUnit(e.target.value as LotUnit)}>
|
||||
{unitOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 text-[12.5px] font-bold text-ink-soft">Match it to your catalog — nothing is auto-created:</div>
|
||||
<div role="radiogroup" aria-label="Which plant this packet is" className="flex flex-col gap-2">
|
||||
{proposal.candidates.map((c, i) => (
|
||||
<MatchOption
|
||||
key={c.plant.id}
|
||||
selected={selection === c.plant.id}
|
||||
onSelect={() => setSelection(c.plant.id)}
|
||||
label={`${c.plant.name} (${isBuiltin(c.plant) ? 'built-in' : 'yours'})`}
|
||||
sub={i === 0 ? `best match · ${c.reason}` : c.reason}
|
||||
/>
|
||||
))}
|
||||
<MatchOption
|
||||
selected={selection === 'new'}
|
||||
onSelect={() => setSelection('new')}
|
||||
label="Create a new plant"
|
||||
sub="from the extracted fields"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selection === 'new' && (
|
||||
<div className="grid grid-cols-2 gap-2.5 rounded-md border border-divider bg-bg p-3.5">
|
||||
<SelectField label="Category" name="category" value={category} onChange={(e) => setCategory(e.target.value as PlantCategory)} options={categoryOptions} />
|
||||
<TextField label={`Spacing (${unitLabel})`} name="spacing" type="number" inputMode="decimal" step="any" min="1" required value={spacing} onChange={(e) => setSpacing(e.target.value)} />
|
||||
<TextField label="Days to maturity" name="days" type="number" inputMode="numeric" step="1" min="1" value={days} onChange={(e) => setDays(e.target.value)} wrapperClassName="col-span-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="mt-0.5 flex justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setProposal(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<span className="flex gap-2">
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add the lot'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchOption({ selected, onSelect, label, sub }: { selected: boolean; onSelect: () => void; label: string; sub: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-full border px-4 py-2.5 text-left',
|
||||
selected ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13.5px] font-bold">{label}</span>
|
||||
<span className="ml-auto text-right text-xs text-ink-mute">{sub}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
lotDefaults,
|
||||
newPlantDefaults,
|
||||
useCreateFromPacket,
|
||||
useScanPacket,
|
||||
type PacketProposal,
|
||||
} from '@/lib/seedPacket'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
PLANT_CATEGORIES,
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
|
||||
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
|
||||
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||
|
||||
// 'new' is "create a new variety"; a number selects that existing candidate plant.
|
||||
type Selection = number | 'new'
|
||||
|
||||
/**
|
||||
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
|
||||
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
|
||||
* review never commits blind — the model can misread, so every committed field is
|
||||
* editable and the human picks "this is an existing plant" vs "a new variety".
|
||||
*
|
||||
* Only offered where `capabilities.vision` is on (the caller gates the entry
|
||||
* point), so a scan should always be possible; a 503 is still handled in case the
|
||||
* model is torn down between the capabilities poll and the upload.
|
||||
*/
|
||||
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||
const scan = useScanPacket()
|
||||
const create = useCreateFromPacket()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
|
||||
// dialog is never a trap the user can only escape by reloading the page.
|
||||
const scanAbort = useRef<AbortController | null>(null)
|
||||
|
||||
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Review-phase fields, seeded from the proposal when a scan lands.
|
||||
const [selection, setSelection] = useState<Selection>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||
const [spacing, setSpacing] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
|
||||
// creating one) and the lot.
|
||||
const [vendor, setVendor] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||
const [sku, setSku] = useState('')
|
||||
const [lotCode, setLotCode] = useState('')
|
||||
const [packedForYear, setPackedForYear] = useState('')
|
||||
const [cost, setCost] = useState('')
|
||||
|
||||
const unitLabel = spacingUnitLabel(unit)
|
||||
const busy = scan.isPending || create.isPending
|
||||
|
||||
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
// Reset the input so re-picking the same file fires change again (e.g. after
|
||||
// an error, retrying the same photo).
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setError(null)
|
||||
const controller = new AbortController()
|
||||
scanAbort.current = controller
|
||||
scan.mutate(
|
||||
{ file, signal: controller.signal },
|
||||
{
|
||||
onSuccess: (p) => {
|
||||
const plant = newPlantDefaults(p)
|
||||
const lot = lotDefaults(p.packet)
|
||||
setProposal(p)
|
||||
// Default to the top candidate when there is one — the likely case is
|
||||
// the packet is a variety already in the catalog — else create a new one.
|
||||
setSelection(p.candidates[0]?.plant.id ?? 'new')
|
||||
setName(plant.name)
|
||||
setCategory(plant.category)
|
||||
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
|
||||
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
|
||||
setVendor(lot.vendor)
|
||||
setQuantity(String(lot.quantity))
|
||||
setLotUnit(lot.unit)
|
||||
setSku(lot.sku)
|
||||
setLotCode(lot.lotCode)
|
||||
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
// Cost isn't on a packet, so it's the one field not reseeded from the
|
||||
// proposal; clear it so a value typed before a Rescan doesn't linger.
|
||||
setCost('')
|
||||
},
|
||||
onError: (err) => {
|
||||
// An aborted scan is a user cancel, not a failure — and Cancel also
|
||||
// closes the dialog, so there's nothing to report.
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!proposal) return
|
||||
setError(null)
|
||||
|
||||
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) {
|
||||
setError('Quantity must be a number, or left blank.')
|
||||
return
|
||||
}
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||
setError('Packed-for year should be a four-digit year.')
|
||||
return
|
||||
}
|
||||
year = y
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) {
|
||||
setError('Cost must be an amount, or left blank.')
|
||||
return
|
||||
}
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
|
||||
const lot = {
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: '',
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: null,
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit: lotUnit,
|
||||
costCents,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
let newPlant: PlantInput | undefined
|
||||
let plantId: number | undefined
|
||||
if (selection === 'new') {
|
||||
if (!name.trim()) {
|
||||
setError('Name the new variety, or pick an existing plant above.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
||||
setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||
return
|
||||
}
|
||||
let daysToMaturity: number | null = null
|
||||
if (days.trim()) {
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) {
|
||||
setError('Days to maturity must be a whole number of days, or left blank.')
|
||||
return
|
||||
}
|
||||
daysToMaturity = d
|
||||
}
|
||||
newPlant = {
|
||||
...newPlantDefaults(proposal),
|
||||
name: name.trim(),
|
||||
category,
|
||||
spacingCm,
|
||||
daysToMaturity,
|
||||
vendor: vendor.trim(),
|
||||
}
|
||||
} else {
|
||||
plantId = selection
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await create.mutateAsync({ plantId, newPlant, lot })
|
||||
toast.info(
|
||||
res.plantIsNew
|
||||
? `Added ${res.plant.name} and its seed lot.`
|
||||
: `Recorded a seed lot for ${res.plant.name}.`,
|
||||
)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, 'Could not save the packet.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
|
||||
{!proposal ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Take a photo of the front of a seed packet and pansy reads the details off it — you review and
|
||||
confirm before anything is saved.
|
||||
</p>
|
||||
|
||||
{/* A hidden input is triggered by the buttons below. `capture` hints a
|
||||
phone to open the camera; on desktop it's ignored and both buttons
|
||||
open a file chooser. */}
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{scan.isPending ? (
|
||||
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
Reading the packet… this can take a few seconds.
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Take or choose a photo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{/* Not disabled while scanning — this is the way out of a slow scan.
|
||||
Aborting a settled/absent request is a harmless no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
scanAbort.current?.abort()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onConfirm} className="flex flex-col gap-4">
|
||||
<ReadFields proposal={proposal} unit={unit} />
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium text-fg">This packet is…</legend>
|
||||
{proposal.candidates.map((c) => (
|
||||
<label
|
||||
key={c.plant.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === c.plant.id}
|
||||
onChange={() => setSelection(c.plant.id)}
|
||||
/>
|
||||
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
|
||||
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
|
||||
<span className="text-xs text-muted">{c.reason}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === 'new'}
|
||||
onChange={() => setSelection('new')}
|
||||
/>
|
||||
<span className="flex-1 font-medium text-fg">
|
||||
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{selection === 'new' && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint="You can set an icon and color later from the plant card."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Category"
|
||||
name="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||
options={categoryOptions}
|
||||
/>
|
||||
<TextField
|
||||
label={`Spacing (${unitLabel})`}
|
||||
name="spacing"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="1"
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The seed lot — what you bought — recorded against whichever plant. */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-fg">Seed lot</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
name="unit"
|
||||
value={lotUnit}
|
||||
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField
|
||||
label="Packed for"
|
||||
name="packedForYear"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2026"
|
||||
value={packedForYear}
|
||||
onChange={(e) => setPackedForYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField
|
||||
label="Cost"
|
||||
name="cost"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="4.99"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// Clear the review-phase error too, or it would show on the
|
||||
// capture screen we're returning to.
|
||||
setError(null)
|
||||
setProposal(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact read-only summary of what the model pulled off the packet, so the
|
||||
* user can see the extraction at a glance while they confirm. Only fields that
|
||||
* came back are shown. */
|
||||
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
|
||||
const p = proposal.packet
|
||||
const rows: [string, string][] = []
|
||||
if (p.species) rows.push(['Species', p.species])
|
||||
if (p.variety) rows.push(['Variety', p.variety])
|
||||
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
|
||||
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
|
||||
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="text-fg">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import {
|
||||
conflictSeedLot,
|
||||
LOT_UNITS,
|
||||
safeExternalUrl,
|
||||
useCreateSeedLot,
|
||||
useUpdateSeedLot,
|
||||
type LotUnit,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
|
||||
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
||||
|
||||
/**
|
||||
* Record a purchase, or correct one. Everything except quantity and unit is
|
||||
* optional — the point is to make writing it down cheap enough to bother with.
|
||||
*/
|
||||
export function SeedLotDialog({ plant, lot, onClose }: { plant: Plant; lot?: SeedLot; onClose: () => void }) {
|
||||
const isEdit = !!lot
|
||||
const create = useCreateSeedLot()
|
||||
const update = useUpdateSeedLot()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
||||
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
||||
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
||||
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
||||
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
||||
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
||||
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
||||
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||
const [notes, setNotes] = useState(lot?.notes ?? '')
|
||||
const [version, setVersion] = useState(lot?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setConflict(null)
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim()))
|
||||
return setError('The source link needs to be a full http:// or https:// address.')
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
|
||||
year = y
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) return setError('Cost must be an amount, or blank.')
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
let germinationPct: number | null = null
|
||||
if (germination.trim()) {
|
||||
const g = Number(germination)
|
||||
if (!Number.isFinite(g) || g < 0 || g > 100) return setError('Germination is a percentage between 0 and 100.')
|
||||
germinationPct = g
|
||||
}
|
||||
const input = {
|
||||
plantId: plant.id,
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit,
|
||||
costCents,
|
||||
germinationPct,
|
||||
notes: notes.trim(),
|
||||
}
|
||||
try {
|
||||
if (isEdit) await update.mutateAsync({ id: lot.id, version, ...input })
|
||||
else await create.mutateAsync(input)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictSeedLot(err)
|
||||
if (current) {
|
||||
setVersion(current.version)
|
||||
setVendor(current.vendor)
|
||||
setSourceUrl(current.sourceUrl)
|
||||
setQuantity(String(current.quantity))
|
||||
setUnit(current.unit)
|
||||
setPurchasedAt(current.purchasedAt ?? '')
|
||||
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
||||
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
||||
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
||||
setSku(current.sku)
|
||||
setLotCode(current.lotCode)
|
||||
setNotes(current.notes)
|
||||
setConflict('This lot changed elsewhere. The latest values are shown — look them over and save again.')
|
||||
return
|
||||
}
|
||||
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={isEdit ? 'Edit the lot' : `A lot of ${plant.name}`} onClose={onClose} busy={pending} width={460}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<TextField label="Quantity" name="quantity" type="number" inputMode="decimal" step="any" min="0" autoFocus value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
<SelectField label="Unit" name="unit" value={unit} onChange={(e) => setUnit(e.target.value as LotUnit)} options={unitOptions} />
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" placeholder="2026" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
|
||||
<TextField label="Purchased" name="purchasedAt" type="date" value={purchasedAt} onChange={(e) => setPurchasedAt(e.target.value)} />
|
||||
<TextField label="Cost" name="cost" type="number" inputMode="decimal" step="0.01" min="0" placeholder="4.99" value={cost} onChange={(e) => setCost(e.target.value)} />
|
||||
<TextField label="Germination %" name="germination" type="number" inputMode="decimal" step="any" min="0" max="100" value={germination} onChange={(e) => setGermination(e.target.value)} />
|
||||
<TextField label="Source link" name="sourceUrl" type="url" inputMode="url" placeholder="https://…" value={sourceUrl} onChange={(e) => setSourceUrl(e.target.value)} />
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
||||
</div>
|
||||
<TextAreaField label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={pending}>
|
||||
Never mind
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record the lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { SourceLink } from './SourceLink'
|
||||
import {
|
||||
formatCost,
|
||||
formatQuantity,
|
||||
formatUnitCost,
|
||||
lotState,
|
||||
type LotState,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
|
||||
const STATE_LABEL: Record<LotState, string> = {
|
||||
over: 'over-planted',
|
||||
empty: 'empty',
|
||||
low: 'low',
|
||||
ok: 'in stock',
|
||||
unknown: 'no count',
|
||||
}
|
||||
|
||||
// Encoded in colour AND words, because this is the thing you skim down a list of
|
||||
// twenty packets deciding what to order — a bare number doesn't survive that.
|
||||
const STATE_CLASS: Record<LotState, string> = {
|
||||
// "over" is a discrepancy to look at rather than a shortage to act on, so it
|
||||
// reads as a distinct warning rather than sharing "low"'s styling.
|
||||
over: 'bg-orange-500/25 text-orange-900 dark:text-orange-200',
|
||||
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
|
||||
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||
ok: 'bg-accent/20 text-accent-strong',
|
||||
unknown: 'bg-border/60 text-muted',
|
||||
}
|
||||
|
||||
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
STATE_CLASS[state],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{STATE_LABEL[state]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A proportional bar for how much of a lot is left, so the state reads before
|
||||
* any of the text does. Omitted when there's no quantity to be a fraction of. */
|
||||
function RemainingBar({ lot }: { lot: SeedLot }) {
|
||||
if (lot.quantity <= 0) return null
|
||||
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
|
||||
const state = lotState(lot)
|
||||
return (
|
||||
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A plant's purchases: what came from where, and what's left of each.
|
||||
*
|
||||
* Two lots of the same variety are two separate rows with independent counts,
|
||||
* which is the whole reason inventory lives on the purchase rather than on the
|
||||
* plant (#50).
|
||||
*/
|
||||
export function SeedLotList({
|
||||
lots,
|
||||
canEdit,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
lots: SeedLot[]
|
||||
canEdit: boolean
|
||||
onAdd: () => void
|
||||
onEdit: (lot: SeedLot) => void
|
||||
onDelete: (lot: SeedLot) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{lots.length === 0 && (
|
||||
<p className="text-xs text-muted">
|
||||
No seed recorded. Add a lot to track what you bought and how much is left.
|
||||
</p>
|
||||
)}
|
||||
{lots.map((lot) => (
|
||||
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
|
||||
))}
|
||||
{canEdit && (
|
||||
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
|
||||
+ Add seed lot
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LotRow({
|
||||
lot,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
lot: SeedLot
|
||||
canEdit: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const cost = formatCost(lot.costCents)
|
||||
const unitCost = formatUnitCost(lot)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-2 py-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
|
||||
{lot.vendor && <span>{lot.vendor}</span>}
|
||||
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
|
||||
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
||||
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
||||
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
||||
<SourceLink url={lot.sourceUrl} />
|
||||
</p>
|
||||
<RemainingBar lot={lot} />
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
||||
>
|
||||
Retire
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
conflictSeedLot,
|
||||
LOT_UNITS,
|
||||
safeExternalUrl,
|
||||
useCreateSeedLot,
|
||||
useUpdateSeedLot,
|
||||
type LotUnit,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Record a purchase, or correct one. Everything except quantity and unit is
|
||||
* optional — the point is to make writing it down cheap enough to bother with,
|
||||
* and a form that demands a SKU and a lot code gets skipped.
|
||||
*/
|
||||
export function SeedLotModal({
|
||||
plant,
|
||||
lot,
|
||||
onClose,
|
||||
}: {
|
||||
plant: Plant
|
||||
/** Editing an existing lot, or undefined to record a new one. */
|
||||
lot?: SeedLot
|
||||
onClose: () => void
|
||||
}) {
|
||||
const isEdit = !!lot
|
||||
const create = useCreateSeedLot()
|
||||
const update = useUpdateSeedLot()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
// A new lot inherits the plant's vendor and source link, since the usual case
|
||||
// is buying the variety you already recorded from the place you recorded it.
|
||||
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
||||
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
||||
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
||||
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
||||
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
||||
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
||||
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
||||
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||
const [notes, setNotes] = useState(lot?.notes ?? '')
|
||||
const [version, setVersion] = useState(lot?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setConflict(null)
|
||||
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) {
|
||||
setError('Quantity must be a number, or left blank.')
|
||||
return
|
||||
}
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||
setError('The source link needs to be a full http:// or https:// address.')
|
||||
return
|
||||
}
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||
setError('Packed-for year should be a four-digit year.')
|
||||
return
|
||||
}
|
||||
year = y
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) {
|
||||
setError('Cost must be an amount, or left blank.')
|
||||
return
|
||||
}
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
let germinationPct: number | null = null
|
||||
if (germination.trim()) {
|
||||
const g = Number(germination)
|
||||
if (!Number.isFinite(g) || g < 0 || g > 100) {
|
||||
setError('Germination is a percentage between 0 and 100.')
|
||||
return
|
||||
}
|
||||
germinationPct = g
|
||||
}
|
||||
|
||||
const input = {
|
||||
plantId: plant.id,
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit,
|
||||
costCents,
|
||||
germinationPct,
|
||||
notes: notes.trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({ id: lot.id, version, ...input })
|
||||
} else {
|
||||
await create.mutateAsync(input)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const current = conflictSeedLot(err)
|
||||
if (current) {
|
||||
// Someone edited this lot elsewhere. Rebase onto the fresh row so a
|
||||
// re-save applies, rather than making them retype everything — the same
|
||||
// contract every other version-guarded form here honours.
|
||||
setVersion(current.version)
|
||||
setVendor(current.vendor)
|
||||
setSourceUrl(current.sourceUrl)
|
||||
setQuantity(String(current.quantity))
|
||||
setUnit(current.unit)
|
||||
setPurchasedAt(current.purchasedAt ?? '')
|
||||
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
||||
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
||||
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
||||
setSku(current.sku)
|
||||
setLotCode(current.lotCode)
|
||||
setNotes(current.notes)
|
||||
setConflict('This lot changed elsewhere. The latest values are shown — review and save again.')
|
||||
return
|
||||
}
|
||||
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
name="unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as LotUnit)}
|
||||
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField
|
||||
label="Source link"
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://…"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Purchased"
|
||||
name="purchasedAt"
|
||||
type="date"
|
||||
value={purchasedAt}
|
||||
onChange={(e) => setPurchasedAt(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Packed for"
|
||||
name="packedForYear"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2026"
|
||||
value={packedForYear}
|
||||
onChange={(e) => setPackedForYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Cost"
|
||||
name="cost"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="4.99"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Germination %"
|
||||
name="germination"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
max="100"
|
||||
value={germination}
|
||||
onChange={(e) => setGermination(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
|
||||
/**
|
||||
* A link out to where seed came from.
|
||||
*
|
||||
* The href is a URL somebody pasted, so it is re-checked here before rendering
|
||||
* and carries rel="noopener noreferrer" — the server scheme-checks it too (#50),
|
||||
* but a link is rendered from whatever the client was handed, and "the backend
|
||||
* validated it" is not a reason to hand javascript: to an anchor tag.
|
||||
*
|
||||
* Renders nothing at all when the URL is absent or unsafe, so callers don't each
|
||||
* have to remember to guard.
|
||||
*/
|
||||
export function SourceLink({ url, label = 'source' }: { url: string; label?: string }) {
|
||||
const safe = safeExternalUrl(url)
|
||||
if (!safe) return null
|
||||
return (
|
||||
<a
|
||||
href={safe}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
>
|
||||
{label} ↗
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -3,15 +3,17 @@ import { cn } from '@/lib/cn'
|
||||
|
||||
type AlertTone = 'error' | 'info'
|
||||
|
||||
/** A small inline notice for form errors and status messages. */
|
||||
export function Alert({ tone = 'error', children }: { tone?: AlertTone; children: ReactNode }) {
|
||||
/** An inline notice. Error = terracotta wash; info = the sage banner the editor
|
||||
* uses for season notes. */
|
||||
export function Alert({ tone = 'error', className, children }: { tone?: AlertTone; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role={tone === 'error' ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-2 text-sm',
|
||||
tone === 'error' && 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
|
||||
tone === 'info' && 'border-border bg-border/30 text-muted',
|
||||
'rounded-md px-3.5 py-2 text-[13px] font-semibold leading-relaxed',
|
||||
tone === 'error' && 'bg-accent-100 text-accent-800',
|
||||
tone === 'info' && 'bg-accent-2-200 text-accent-2-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,25 +1,84 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { Icon, type IconName } from './Icon'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'ghost' | 'danger'
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'plain'
|
||||
|
||||
/** Shared button styling, exported so anchor "buttons" (e.g. the OIDC link) match. */
|
||||
export function buttonClasses(variant: ButtonVariant = 'primary', className?: string) {
|
||||
/** The pill button classes, exported so anchor "buttons" (the OIDC link, card
|
||||
* Open links) match real buttons exactly. */
|
||||
export function buttonClass(variant: ButtonVariant = 'secondary', className?: string): string {
|
||||
return cn(
|
||||
'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
variant === 'primary' && 'bg-accent text-accent-contrast hover:bg-accent-strong',
|
||||
variant === 'ghost' && 'border border-border text-fg hover:bg-border/50',
|
||||
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500/40',
|
||||
'btn',
|
||||
variant === 'primary' && 'btn-primary',
|
||||
variant === 'secondary' && 'btn-secondary',
|
||||
variant === 'ghost' && 'btn-ghost',
|
||||
variant === 'plain' && 'btn-soft',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
/** A leading Lucide glyph. */
|
||||
icon?: IconName
|
||||
iconSize?: number
|
||||
/** Phone hit targets are ≥ 44px. */
|
||||
tall?: boolean
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', className, type = 'button', ...props }: ButtonProps) {
|
||||
return <button type={type} className={buttonClasses(variant, className)} {...props} />
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
icon,
|
||||
iconSize = 14,
|
||||
tall,
|
||||
className,
|
||||
type = 'button',
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button type={type} className={cn(buttonClass(variant), tall && 'min-h-11', className)} {...props}>
|
||||
{icon && <Icon name={icon} size={iconSize} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Accessible name AND tooltip — an icon-only control always says what it does. */
|
||||
label: string
|
||||
icon: IconName
|
||||
iconSize?: number
|
||||
variant?: ButtonVariant
|
||||
/** Pixel size of the round button (36 by default; 38/44 on the phone). */
|
||||
size?: number
|
||||
iconClassName?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
iconSize = 15,
|
||||
variant = 'secondary',
|
||||
size,
|
||||
className,
|
||||
iconClassName,
|
||||
type = 'button',
|
||||
children,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(buttonClass(variant), 'btn-icon', className)}
|
||||
style={size ? { width: size, height: size } : undefined}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
<Icon name={icon} size={iconSize} className={iconClassName} />
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { Alert } from './Alert'
|
||||
import { Button } from './Button'
|
||||
import { Dialog } from './Dialog'
|
||||
|
||||
/**
|
||||
* A confirm-and-act dialog: a message, then Never mind / Confirm. Owns the busy
|
||||
* lock, the inline error on failure (so a 409 like PLANT_IN_USE is shown, not
|
||||
* swallowed), and the footer. onConfirm resolving closes the dialog; it throwing
|
||||
* keeps the dialog open with the error and re-enables the button for a retry.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
busyLabel,
|
||||
cancelLabel = 'Never mind',
|
||||
confirmDisabled = false,
|
||||
errorFallback,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
confirmLabel: string
|
||||
busyLabel: string
|
||||
cancelLabel?: string
|
||||
confirmDisabled?: boolean
|
||||
errorFallback: string
|
||||
onConfirm: () => Promise<unknown>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, errorFallback))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={title} onClose={onClose} busy={busy}>
|
||||
<div className="text-sm leading-relaxed text-ink-soft">{children}</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm} disabled={busy || confirmDisabled}>
|
||||
{busy ? busyLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
|
||||
* shape every confirmation repeated by hand — the busy lock, the inline error on
|
||||
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
|
||||
* so each caller supplies only its message, its action, and its labels.
|
||||
*
|
||||
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
|
||||
* the dialog open with the error and re-enables the button for a retry. This is
|
||||
* confirmations only — dialogs with their own inputs (a rename, a form) keep
|
||||
* using Modal directly.
|
||||
*/
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
children,
|
||||
confirmLabel,
|
||||
busyLabel,
|
||||
confirmVariant = 'danger',
|
||||
confirmDisabled = false,
|
||||
errorFallback,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
/** The body — what's being confirmed. */
|
||||
children: ReactNode
|
||||
confirmLabel: string
|
||||
/** Label while the action is in flight (e.g. "Deleting…"). */
|
||||
busyLabel: string
|
||||
confirmVariant?: 'danger' | 'primary'
|
||||
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
|
||||
confirmDisabled?: boolean
|
||||
/** Message if the action throws something without its own user-facing text. */
|
||||
errorFallback: string
|
||||
onConfirm: () => Promise<unknown>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, errorFallback))
|
||||
setBusy(false) // keep the dialog open so the message shows and retry works
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={title} onClose={onClose} busy={busy}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{children}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
disabled={busy || confirmDisabled}
|
||||
>
|
||||
{busy ? busyLabel : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||
// break the wrap math.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* A centered dialog over a dimmed backdrop — the design's `.dialog` card. Closes
|
||||
* on Escape or a backdrop click unless `busy` (a mutation is in flight), so an
|
||||
* action can finish and report. Focus is trapped inside and returned to the
|
||||
* opener on close. The caller owns open/closed state (render only when open).
|
||||
*/
|
||||
export function Dialog({
|
||||
title,
|
||||
onClose,
|
||||
busy = false,
|
||||
width = 420,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
busy?: boolean
|
||||
/** Card width in px (capped at the viewport). */
|
||||
width?: number
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
// Latest onClose/busy in refs so the mount-only effect never re-runs (which
|
||||
// would re-attach the listener and steal focus on every parent re-render).
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
const busyRef = useRef(busy)
|
||||
busyRef.current = busy
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current
|
||||
const opener = document.activeElement as HTMLElement | null
|
||||
// Land on the first control if there is one, else the card itself.
|
||||
const first = card?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
;(first ?? card)?.focus()
|
||||
|
||||
const focusable = () => Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !busyRef.current) {
|
||||
e.stopPropagation()
|
||||
onCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusable()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
card?.focus()
|
||||
return
|
||||
}
|
||||
const firstItem = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
if (!card || !card.contains(active)) {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? last : firstItem).focus()
|
||||
return
|
||||
}
|
||||
if (e.shiftKey && (active === firstItem || active === card)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
firstItem.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
if (opener && opener.isConnected) opener.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className={cn('dialog', className)}
|
||||
style={{ width: `min(${width}px, 100%)` }}
|
||||
>
|
||||
<h3 className="text-[22px]">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** A horizontal rule with centered label text (e.g. "or" between auth options). */
|
||||
export function Divider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{children}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
type SelectHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** The field id: an explicit id, else the name, else a generated stable id, so
|
||||
* the label's htmlFor always binds to something. */
|
||||
function useFieldId(id?: string, name?: string): string {
|
||||
const generated = useId()
|
||||
return id ?? name ?? generated
|
||||
}
|
||||
|
||||
/** A labelled control: the design's `.field` (12px label above a pill input). */
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode
|
||||
htmlFor?: string
|
||||
hint?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('field', className)}>
|
||||
<label htmlFor={htmlFor}>{label}</label>
|
||||
{children}
|
||||
{hint && <p className="mt-1 text-xs leading-relaxed text-ink-mute">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: ReactNode
|
||||
hint?: ReactNode
|
||||
/** 16px font + 44px height for phone forms (stops iOS zoom). */
|
||||
large?: boolean
|
||||
wrapperClassName?: string
|
||||
}
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||
{ label, hint, large, id, name, className, wrapperClassName, ...props },
|
||||
ref,
|
||||
) {
|
||||
const inputId = useFieldId(id, name)
|
||||
return (
|
||||
<Field label={label} htmlFor={inputId} hint={hint} className={wrapperClassName}>
|
||||
<input ref={ref} id={inputId} name={name} className={cn('input', large && 'input-lg', className)} {...props} />
|
||||
</Field>
|
||||
)
|
||||
})
|
||||
|
||||
interface SelectFieldProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: ReactNode
|
||||
options: { value: string; label: string }[]
|
||||
hint?: ReactNode
|
||||
wrapperClassName?: string
|
||||
}
|
||||
|
||||
export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(function SelectField(
|
||||
{ label, options, hint, id, name, className, wrapperClassName, ...props },
|
||||
ref,
|
||||
) {
|
||||
const fieldId = useFieldId(id, name)
|
||||
return (
|
||||
<Field label={label} htmlFor={fieldId} hint={hint} className={wrapperClassName}>
|
||||
<select ref={ref} id={fieldId} name={name} className={cn('input', className)} {...props}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)
|
||||
})
|
||||
|
||||
interface TextAreaFieldProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: ReactNode
|
||||
hint?: ReactNode
|
||||
wrapperClassName?: string
|
||||
}
|
||||
|
||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextAreaField(
|
||||
{ label, hint, id, name, className, wrapperClassName, ...props },
|
||||
ref,
|
||||
) {
|
||||
const fieldId = useFieldId(id, name)
|
||||
return (
|
||||
<Field label={label} htmlFor={fieldId} hint={hint} className={wrapperClassName}>
|
||||
<textarea ref={ref} id={fieldId} name={name} className={cn('input', className)} {...props} />
|
||||
</Field>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
// Lucide glyphs (https://lucide.dev), inlined at the design's stroke-width 2.75
|
||||
// with round caps and joins. Each entry is the element list of one 24×24 icon:
|
||||
// a path `d`, or `c:cx,cy,r` for a circle, or `r:x,y,w,h,rx` for a rect.
|
||||
const GLYPHS = {
|
||||
sprout: [
|
||||
'M7 20h10',
|
||||
'M10 20c5.5-2.5.8-6.4 3-10',
|
||||
'M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z',
|
||||
'M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z',
|
||||
],
|
||||
shovel: ['M2 22v-5l5-5 5 5-5 5z', 'M9.5 14.5 16 8', 'm17 2 5 5-.5.5a3.53 3.53 0 0 1-5 0v0a3.53 3.53 0 0 1 0-5L17 2'],
|
||||
notebook: ['M2 6h4', 'M2 10h4M2 14h4M2 18h4', 'M8 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z'],
|
||||
'message-circle': ['M7.9 20A9 9 0 1 0 4 16.1L2 22Z'],
|
||||
settings: [
|
||||
'c:12,12,3',
|
||||
'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0 2-2z',
|
||||
],
|
||||
'undo-2': ['M9 14 4 9l5-5', 'M4 9h10.5a5.5 5.5 0 0 1 0 11H11'],
|
||||
'rotate-cw': ['M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8', 'M21 3v5h-5'],
|
||||
'trash-2': ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'],
|
||||
plus: ['M5 12h14', 'M12 5v14'],
|
||||
minus: ['M5 12h14'],
|
||||
maximize: ['M8 3H5a2 2 0 0 0-2 2v3', 'M21 8V5a2 2 0 0 0-2-2h-3', 'M3 16v3a2 2 0 0 0 2 2h3', 'M16 21h3a2 2 0 0 0 2-2v-3'],
|
||||
'chevron-left': ['m15 18-6-6 6-6'],
|
||||
'chevron-right': ['m9 18 6-6-6-6'],
|
||||
'chevron-down': ['m6 9 6 6 6-6'],
|
||||
x: ['M18 6 6 18', 'm6 6 12 12'],
|
||||
camera: ['M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z', 'c:12,13,3'],
|
||||
'share-2': ['c:18,5,3', 'c:6,12,3', 'c:18,19,3', 'm8.6 10.6 6.8-3.9', 'm8.6 13.4 6.8 3.9'],
|
||||
copy: ['r:8,8,14,14,2', 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'],
|
||||
lock: ['r:3,11,18,11,2', 'M7 11V7a5 5 0 0 1 10 0v4'],
|
||||
monitor: ['M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z', 'M8 21h8', 'M12 17v4'],
|
||||
sun: ['M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8', 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4'],
|
||||
moon: ['M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z'],
|
||||
search: ['c:11,11,8', 'm21 21-4.3-4.3'],
|
||||
send: ['m22 2-7 20-4-9-9-4Z', 'M22 2 11 13'],
|
||||
pencil: [
|
||||
'M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z',
|
||||
'm15 5 4 4',
|
||||
],
|
||||
'log-out': ['M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'],
|
||||
check: ['M20 6 9 17l-5-5'],
|
||||
eye: ['M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0', 'c:12,12,3'],
|
||||
'external-link': ['M15 3h6v6', 'M10 14 21 3', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'],
|
||||
'rows-3': ['r:3,3,18,18,2', 'M21 9H3', 'M21 15H3'],
|
||||
eraser: ['m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21', 'M22 21H7', 'm5 11 9 9'],
|
||||
'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
|
||||
upload: ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'm17 8-5-5-5 5', 'M12 3v12'],
|
||||
'triangle-alert': ['m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01'],
|
||||
square: ['r:3,3,18,18,2'],
|
||||
history: ['M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'M3 3v5h5', 'M12 7v5l4 2'],
|
||||
link: [
|
||||
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
||||
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||
],
|
||||
'arrow-left': ['m12 19-7-7 7-7', 'M19 12H5'],
|
||||
'more-horizontal': ['c:12,12,1', 'c:19,12,1', 'c:5,12,1'],
|
||||
} as const
|
||||
|
||||
export type IconName = keyof typeof GLYPHS
|
||||
|
||||
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
|
||||
name: IconName
|
||||
size?: number
|
||||
}
|
||||
|
||||
/** One Lucide icon at the design's 2.75 stroke; color comes from currentColor
|
||||
* unless a `stroke` is given. Decorative by default (aria-hidden) — the
|
||||
* control around it carries the label. */
|
||||
export function Icon({ name, size = 15, stroke, ...rest }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={stroke ?? 'currentColor'}
|
||||
strokeWidth={2.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
{...rest}
|
||||
>
|
||||
{GLYPHS[name].map((el, i) => {
|
||||
if (el.startsWith('c:')) {
|
||||
const [cx, cy, r] = el.slice(2).split(',').map(Number)
|
||||
return <circle key={i} cx={cx} cy={cy} r={r} />
|
||||
}
|
||||
if (el.startsWith('r:')) {
|
||||
const [x, y, w, h, rx] = el.slice(2).split(',').map(Number)
|
||||
return <rect key={i} x={x} y={y} width={w} height={h} rx={rx} />
|
||||
}
|
||||
return <path key={i} d={el} />
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/**
|
||||
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
||||
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
||||
* action can finish and report. The caller owns open/closed state (render only
|
||||
* when open).
|
||||
*/
|
||||
export function Modal({
|
||||
title,
|
||||
onClose,
|
||||
busy = false,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
busy?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
// Keep the latest onClose/busy in refs so the mount-only effect below never
|
||||
// re-runs (which would re-attach the listener and steal focus on every parent
|
||||
// re-render, e.g. during a background refetch).
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
const busyRef = useRef(busy)
|
||||
busyRef.current = busy
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current
|
||||
// Remember who opened the dialog so focus can return there on close —
|
||||
// otherwise it lands on <body> and a keyboard user loses their place.
|
||||
const opener = document.activeElement as HTMLElement | null
|
||||
card?.focus()
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !busyRef.current) {
|
||||
onCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const items = focusable()
|
||||
if (items.length === 0) {
|
||||
e.preventDefault()
|
||||
card?.focus()
|
||||
return
|
||||
}
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
const active = document.activeElement
|
||||
// If focus is NOT inside the dialog, pull it back in rather than let Tab
|
||||
// escape. This is the robust case that covers focus having fallen to
|
||||
// <body> — a control that was removed (ShareGardenModal's remove-share
|
||||
// button) or disabled while busy — as well as any externally-stolen focus.
|
||||
if (!card || !card.contains(active)) {
|
||||
e.preventDefault()
|
||||
;(e.shiftKey ? last : first).focus()
|
||||
return
|
||||
}
|
||||
if (e.shiftKey && (active === first || active === card)) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
// Restore focus to the opener only if it's still in the document — the
|
||||
// delete/clear flows this trap targets often remove the element that
|
||||
// opened the dialog (a garden card, a plop row). A disconnected node's
|
||||
// focus() silently no-ops and leaves focus on <body>, so fall through to
|
||||
// that case explicitly rather than pretend it worked.
|
||||
if (opener && opener.isConnected) opener.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-lg outline-none"
|
||||
>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export interface SegOption<T extends string> {
|
||||
value: T
|
||||
label: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The segmented pill control: options on a neutral-200 track, the active one
|
||||
* lifted on a neutral-100 pill with a soft shadow. Used for seasons, the theme,
|
||||
* units, and read-only status displays (pass `disabled`).
|
||||
*/
|
||||
export function Seg<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
optionClassName,
|
||||
label,
|
||||
}: {
|
||||
options: SegOption<T>[]
|
||||
value: T
|
||||
onChange?: (v: T) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
optionClassName?: string
|
||||
/** Accessible group name. */
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('seg', className)} role="group" aria-label={label}>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={cn('seg-opt', optionClassName)}
|
||||
aria-pressed={o.value === value}
|
||||
disabled={disabled}
|
||||
title={o.title}
|
||||
onClick={() => onChange?.(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass, useFieldId } from './field'
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label: string
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ label, options, id, name, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const fieldId = useFieldId(id, name)
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<select ref={ref} id={fieldId} name={name} className={cn(fieldControlClass, className)} {...props}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export type TagTone = 'accent' | 'accent-2' | 'neutral' | 'outline'
|
||||
|
||||
export function Tag({ tone = 'neutral', className, ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: TagTone }) {
|
||||
return <span className={cn('tag', `tag-${tone}`, className)} {...props} />
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass, useFieldId } from './field'
|
||||
|
||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
||||
{ label, id, name, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const fieldId = useFieldId(id, name)
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<textarea ref={ref} id={fieldId} name={name} className={cn(fieldControlClass, className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass, useFieldId } from './field'
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||
{ label, hint, id, name, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const inputId = useFieldId(id, name)
|
||||
const hintId = hint ? `${inputId}-hint` : undefined
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
name={name}
|
||||
aria-describedby={hintId}
|
||||
className={cn(fieldControlClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
{hint && (
|
||||
<p id={hintId} className="text-xs text-muted">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** The On/Off pill. Sage when on, neutral when off. */
|
||||
export function Toggle({
|
||||
on,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
onLabel = 'On',
|
||||
offLabel = 'Off',
|
||||
className,
|
||||
}: {
|
||||
on: boolean
|
||||
onChange?: (next: boolean) => void
|
||||
disabled?: boolean
|
||||
/** Accessible name for the switch. */
|
||||
label: string
|
||||
onLabel?: string
|
||||
offLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-pressed={on}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={cn('toggle', className)}
|
||||
onClick={() => onChange?.(!on)}
|
||||
>
|
||||
{on ? onLabel : offLabel}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Shared footer-action button styling for list cards (garden/plant), so the
|
||||
// verbatim class strings don't drift between them.
|
||||
//
|
||||
// Sized for touch: a ~40px-tall tap target (min-h + py-2) rather than the old
|
||||
// ~28px text-link row, which was easy to mis-tap on a phone (#105). The min-h is
|
||||
// what guarantees the target even when the label is short.
|
||||
|
||||
const cardActionBase =
|
||||
'inline-flex min-h-[2.5rem] items-center rounded-md px-3 py-2 text-sm font-medium ' +
|
||||
'text-muted outline-none transition-colors focus-visible:ring-2 '
|
||||
|
||||
export const cardActionClass =
|
||||
cardActionBase + 'hover:bg-border/50 hover:text-fg focus-visible:ring-accent/40'
|
||||
|
||||
export const cardDangerClass =
|
||||
cardActionBase +
|
||||
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-red-500/40 dark:hover:text-red-400'
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useId } from 'react'
|
||||
|
||||
// Shared field plumbing for the labelled form controls (TextField, TextArea,
|
||||
// Select) so their id-fallback and base styling stay in one place.
|
||||
|
||||
/** The field id: an explicit id, else the name, else a generated stable id, so
|
||||
* the label's htmlFor always binds to something. */
|
||||
export function useFieldId(id?: string, name?: string): string {
|
||||
const generated = useId()
|
||||
return id ?? name ?? generated
|
||||
}
|
||||
|
||||
/** Base classes shared by the text/select/textarea controls. text-base (16px)
|
||||
* keeps iOS Safari from zooming on focus. */
|
||||
export const fieldControlClass =
|
||||
'w-full rounded-md border border-border bg-surface px-3 py-2 text-base text-fg ' +
|
||||
'outline-none transition-colors placeholder:text-muted/70 ' +
|
||||
'focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60'
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
type ToastTone = 'info' | 'error'
|
||||
interface Toast {
|
||||
@@ -17,10 +18,8 @@ interface ToastState {
|
||||
|
||||
let nextId = 1
|
||||
|
||||
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||
// happened — is always visible.
|
||||
// Error toasts don't auto-dismiss (#85), so a burst of failures could grow the
|
||||
// stack without bound; keep the most recent few so the newest is always visible.
|
||||
const MAX_TOASTS = 4
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
@@ -36,14 +35,12 @@ export const toast = {
|
||||
error: (m: string) => useToastStore.getState().push(m, 'error'),
|
||||
}
|
||||
|
||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||
function ToastItem({ item }: { item: Toast }) {
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
const isError = item.tone === 'error'
|
||||
useEffect(() => {
|
||||
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||
// Errors are the primary report that a mutation failed, so they stay until
|
||||
// dismissed; info toasts time out.
|
||||
if (isError) return
|
||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||
return () => clearTimeout(t)
|
||||
@@ -52,10 +49,8 @@ function ToastItem({ item }: { item: Toast }) {
|
||||
<div
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
isError
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: 'border-border bg-surface text-fg',
|
||||
'elev-md pointer-events-auto flex items-center gap-2.5 rounded-full border py-2 pl-4 pr-2 text-[13px] font-semibold',
|
||||
isError ? 'border-accent-300 bg-accent-100 text-accent-800' : 'border-divider bg-neutral-100 text-text',
|
||||
)}
|
||||
>
|
||||
<span className="flex-1">{item.message}</span>
|
||||
@@ -63,20 +58,20 @@ function ToastItem({ item }: { item: Toast }) {
|
||||
type="button"
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
||||
className="btn btn-icon btn-soft !h-7 !w-7 text-current"
|
||||
>
|
||||
✕
|
||||
<Icon name="x" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fixed stack of active toasts. Mount once near the app root. */
|
||||
/** Fixed stack of active toasts. Mounted once in the app shell. */
|
||||
export function Toaster() {
|
||||
const toasts = useToastStore((s) => s.toasts)
|
||||
if (toasts.length === 0) return null
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
|
||||
<div className="pointer-events-none fixed inset-x-4 bottom-[calc(16px+env(safe-area-inset-bottom))] z-[60] flex flex-col items-center gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} item={t} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn } from '@/lib/agent'
|
||||
import type { useUndo } from '@/lib/history'
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
|
||||
// Lazy so the markdown renderer loads only when an assistant message renders.
|
||||
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||
|
||||
/** Falls back to the raw text if the markdown chunk can't load or throws. */
|
||||
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talk to the garden assistant beside the canvas — watching the plan change as
|
||||
* it works IS the confirmation. Every turn lands as one undoable step, so each
|
||||
* assistant reply that changed something carries its own Undo.
|
||||
*/
|
||||
export function AssistantTab({ gardenId, canEdit, undo, large = false }: { gardenId: number; canEdit: boolean; undo: ReturnType<typeof useUndo>; large?: boolean }) {
|
||||
const history = useAgentHistory(gardenId, true)
|
||||
const clear = useClearAgentHistory(gardenId)
|
||||
const refresh = useAgentRefresh(gardenId)
|
||||
const [input, setInput] = useState('')
|
||||
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [warning, setWarning] = useState<string | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
const bottom = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deliberately NOT aborted on unmount: selecting a bed switches the rail to
|
||||
// the inspector, and that must not kill a turn mid-flight. The request runs
|
||||
// on; the exchange is persisted server-side; coming back shows it.
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [history.data, pending])
|
||||
|
||||
const send = () => {
|
||||
const message = input.trim()
|
||||
if (!message || pending) return
|
||||
setInput('')
|
||||
setError(null)
|
||||
setWarning(null)
|
||||
setPending({ message, steps: [] })
|
||||
const controller = new AbortController()
|
||||
abort.current = controller
|
||||
void streamChat(
|
||||
gardenId,
|
||||
message,
|
||||
{
|
||||
onStep: (step) => {
|
||||
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||
refresh.canvas()
|
||||
},
|
||||
onDone: (turn: AgentTurn) => {
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
if (turn.truncated) setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||
},
|
||||
onWarning: setWarning,
|
||||
onError: (m) => {
|
||||
setPending(null)
|
||||
setError(m)
|
||||
refresh.everything()
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
abort.current?.abort()
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
}
|
||||
|
||||
const messages = history.data ?? []
|
||||
const bubbleText = large ? 'text-[13.5px]' : 'text-[13px]'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||
{!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
|
||||
{history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
|
||||
{history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}
|
||||
{history.isSuccess && messages.length === 0 && !pending && (
|
||||
<p className="text-[13px] leading-relaxed text-ink-mute">
|
||||
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the north half of the
|
||||
west bed with beans”, “what's in here?”. Everything it does lands as one change you can undo.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m) =>
|
||||
m.role === 'user' ? (
|
||||
<div key={m.id} className={cn('self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText, 'max-w-[85%]')}>
|
||||
{m.body}
|
||||
</div>
|
||||
) : (
|
||||
<div key={m.id} className="flex max-w-[90%] flex-col items-start gap-1 self-start">
|
||||
<div className={cn('rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
|
||||
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
|
||||
<MarkdownMessage>{m.body}</MarkdownMessage>
|
||||
</Suspense>
|
||||
</MarkdownBoundary>
|
||||
</div>
|
||||
{m.changeSetId != null && canEdit && <TurnUndo changeSetId={m.changeSetId} undo={undo} />}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{pending && (
|
||||
<>
|
||||
<div className={cn('max-w-[85%] self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText)}>
|
||||
{pending.message}
|
||||
</div>
|
||||
<div className={cn('max-w-[90%] self-start rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
|
||||
{pending.steps.map((s) => (
|
||||
<div key={s.index} className="text-xs text-ink-mute">
|
||||
{describeStep(s)}…
|
||||
</div>
|
||||
))}
|
||||
<p className="flex items-center gap-1.5 text-xs text-ink-mute">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
||||
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{warning && <Alert tone="info">{warning}</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div ref={bottom} />
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-1.5">
|
||||
{messages.length > 0 && !pending && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost self-end px-2 py-0.5 text-[11.5px]"
|
||||
disabled={clear.isPending}
|
||||
onClick={() => clear.mutate(undefined, { onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")) })}
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className={cn('input', large && 'input-lg')}
|
||||
placeholder="Ask about your garden…"
|
||||
aria-label="Message the assistant"
|
||||
value={input}
|
||||
disabled={!!pending}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pending ? (
|
||||
<IconButton label="Stop" icon="square" iconSize={large ? 16 : 15} size={large ? 44 : 36} onClick={stop} />
|
||||
) : (
|
||||
<IconButton label="Send" icon="send" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={!input.trim()} onClick={send} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Undo on the turn itself, so the common case never involves the History tab. */
|
||||
function TurnUndo({ changeSetId, undo }: { changeSetId: number; undo: ReturnType<typeof useUndo> }) {
|
||||
const outcome = undo.outcomeFor(changeSetId)
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5 pl-1">
|
||||
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" disabled={outcome?.tone === 'pending'} onClick={() => undo.undo({ id: changeSetId })}>
|
||||
{outcome?.tone === 'pending' ? 'Undoing…' : 'Undo this'}
|
||||
</button>
|
||||
{outcome && outcome.tone !== 'pending' && (
|
||||
<p role="status" className={cn('text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react'
|
||||
import { clampScale, type Point } from '@/lib/geometry'
|
||||
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { formatSize } from '@/lib/units'
|
||||
import { kindDef, objectStyle, rectRadius } from './kinds'
|
||||
import {
|
||||
DIM_OBJECT,
|
||||
DIM_PLOP,
|
||||
MAX_SCALE,
|
||||
MIN_OBJECT_CM,
|
||||
MIN_SCALE,
|
||||
SNAP_CM,
|
||||
clampPlopLocal,
|
||||
defaultPlopRadius,
|
||||
objectAt,
|
||||
objectTransform,
|
||||
rotatedHalfHeight,
|
||||
snapPlopLocal,
|
||||
toLocal,
|
||||
toWorld,
|
||||
} from './shared'
|
||||
import { useEditorStore, type Viewport } from './store'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
|
||||
const WHEEL_SENSITIVITY = 0.0016
|
||||
const ANIM_MS = 520
|
||||
const REFIT_THRESHOLD_PX = 60
|
||||
const FT = 30.48
|
||||
|
||||
export interface CanvasHandle {
|
||||
zoomFit: () => void
|
||||
zoomIn: () => void
|
||||
zoomOut: () => void
|
||||
/** Frame a bed and enter focus mode (the page handles mode + URL). */
|
||||
focusObject: (o: EditorObject) => void
|
||||
}
|
||||
|
||||
type Drag =
|
||||
| { t: 'pan'; sx: number; sy: number; tx: number; ty: number; moved: boolean; th: number }
|
||||
| { t: 'obj'; base: EditorObject; sx: number; sy: number; moved: boolean; th: number }
|
||||
| { t: 'plop'; base: EditorPlanting; obj: EditorObject; sx: number; sy: number; moved: boolean; th: number }
|
||||
| { t: 'resize'; base: EditorObject; cx: number; cy: number; opp: Point; moved: boolean }
|
||||
|
||||
interface Pinch {
|
||||
d0: number
|
||||
cx0: number
|
||||
cy0: number
|
||||
s0: number
|
||||
tx0: number
|
||||
ty0: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The garden canvas: one SVG, world = garden cm under a single translate/scale
|
||||
* group. Everything the prototype does — wheel zoom to the cursor, two-finger
|
||||
* pinch about the centroid, one-finger pan, drag-to-move with a 3″ snap, tap to
|
||||
* select, semantic zoom on the plant markers, a ghost while a kind is armed,
|
||||
* drag-and-drop from the toolkit — lives here, with native SVG hit-testing
|
||||
* doing the picking. Drags commit ONE change on release (a PATCH with the row's
|
||||
* version); placement creates. Corner handles on the selected object resize it.
|
||||
*/
|
||||
export const Canvas = forwardRef<
|
||||
CanvasHandle,
|
||||
{
|
||||
garden: EditorGarden
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
letters: Map<number, string>
|
||||
canEdit: boolean
|
||||
isMobile: boolean
|
||||
}
|
||||
>(function Canvas({ garden, objects, plantings, plantsById, letters, canEdit, isMobile }, ref) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||
const fitSize = useRef({ w: 0, h: 0 })
|
||||
const fittedGarden = useRef<number | null>(null)
|
||||
const wasMobile = useRef(isMobile)
|
||||
const animTimer = useRef<number | null>(null)
|
||||
const pts = useRef(new Map<number, Point>())
|
||||
const drag = useRef<Drag | null>(null)
|
||||
const pinch = useRef<Pinch | null>(null)
|
||||
const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null)
|
||||
|
||||
const vp = useEditorStore((s) => s.vp)
|
||||
const anim = useEditorStore((s) => s.anim)
|
||||
const sel = useEditorStore((s) => s.sel)
|
||||
const focusId = useEditorStore((s) => s.focusId)
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const ghost = useEditorStore((s) => s.ghost)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
|
||||
const createObject = useCreateObject(garden.id)
|
||||
const createPlanting = useCreatePlanting(garden.id)
|
||||
const updateObject = useUpdateObject(garden.id)
|
||||
const updatePlanting = useUpdatePlanting(garden.id)
|
||||
|
||||
const GW = garden.widthCm
|
||||
const GH = garden.heightCm
|
||||
const s = vp.s
|
||||
const snapStep = garden.snapToGrid && garden.gridSizeCm > 0 ? garden.gridSizeCm : SNAP_CM
|
||||
|
||||
// Objects in draw order, with any in-flight drag geometry merged in.
|
||||
const rendered = useMemo(() => {
|
||||
const sorted = [...objects].sort((a, b) => a.zIndex - b.zIndex || a.id - b.id)
|
||||
return liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted
|
||||
}, [objects, liveObject])
|
||||
const renderedPlops = useMemo(
|
||||
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
|
||||
[plantings, livePlanting],
|
||||
)
|
||||
const byId = useMemo(() => new Map(rendered.map((o) => [o.id, o])), [rendered])
|
||||
// Latest lists for the pointer handlers, which must not close over a render.
|
||||
const latest = useRef({ rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep })
|
||||
latest.current = { rendered, renderedPlops, byId, canEdit, isMobile, garden, snapStep }
|
||||
|
||||
// ── camera ──────────────────────────────────────────────────────────────
|
||||
const camera = useCallback((next: Viewport, animate: boolean) => {
|
||||
useEditorStore.getState().setVp(next, animate)
|
||||
if (animTimer.current != null) window.clearTimeout(animTimer.current)
|
||||
if (animate) {
|
||||
animTimer.current = window.setTimeout(() => {
|
||||
animTimer.current = null
|
||||
useEditorStore.getState().setAnim(false)
|
||||
}, ANIM_MS)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const zoomFit = useCallback(() => {
|
||||
const el = svgRef.current
|
||||
if (!el) return
|
||||
const w = el.clientWidth
|
||||
const h = el.clientHeight
|
||||
const pad = latest.current.isMobile ? 20 : 40
|
||||
const { widthCm, heightCm } = latest.current.garden
|
||||
const ns = clampScale(Math.min((w - pad * 2) / widthCm, (h - pad * 2) / heightCm), MIN_SCALE, MAX_SCALE)
|
||||
if (!Number.isFinite(ns) || ns <= 0) return
|
||||
fitSize.current = { w, h }
|
||||
camera({ s: ns, tx: (w - widthCm * ns) / 2, ty: (h - heightCm * ns) / 2 }, true)
|
||||
}, [camera])
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(f: number) => {
|
||||
const el = svgRef.current
|
||||
if (!el) return
|
||||
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||
const ns = clampScale(cur * f, MIN_SCALE, MAX_SCALE)
|
||||
const px = el.clientWidth / 2
|
||||
const py = el.clientHeight / 2
|
||||
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, true)
|
||||
},
|
||||
[camera],
|
||||
)
|
||||
|
||||
const focusObject = useCallback(
|
||||
(o: EditorObject) => {
|
||||
const el = svgRef.current
|
||||
const st = useEditorStore.getState()
|
||||
const m = latest.current.isMobile
|
||||
st.setFocus(o.id)
|
||||
st.setSel(m ? null : { type: 'object', id: o.id })
|
||||
st.setArmedKind(null)
|
||||
st.setGhost(null)
|
||||
st.setTab('plot')
|
||||
if (!el) return
|
||||
const bw = o.rotationDeg % 180 ? o.heightCm : o.widthCm
|
||||
const bh = o.rotationDeg % 180 ? o.widthCm : o.heightCm
|
||||
const ns = Math.min(
|
||||
MAX_SCALE * 0.75,
|
||||
Math.min(el.clientWidth / (bw * (m ? 1.35 : 2.1)), el.clientHeight / (bh * (m ? 1.45 : 1.7))),
|
||||
)
|
||||
camera({ s: ns, tx: el.clientWidth / 2 - o.xCm * ns, ty: el.clientHeight / 2 - o.yCm * ns + (m ? 0 : 10) }, true)
|
||||
},
|
||||
[camera],
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({ zoomFit, zoomIn: () => zoomBy(1.45), zoomOut: () => zoomBy(1 / 1.45), focusObject }), [
|
||||
zoomFit,
|
||||
zoomBy,
|
||||
focusObject,
|
||||
])
|
||||
|
||||
// Measure; refit on a real size change (>60px) or when the chrome flips.
|
||||
useEffect(() => {
|
||||
const el = hostRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (size.w === 0 || size.h === 0 || GW === 0 || GH === 0) return
|
||||
const first = fittedGarden.current !== garden.id
|
||||
const flipped = wasMobile.current !== isMobile
|
||||
const big =
|
||||
Math.abs(size.w - fitSize.current.w) > REFIT_THRESHOLD_PX || Math.abs(size.h - fitSize.current.h) > REFIT_THRESHOLD_PX
|
||||
wasMobile.current = isMobile
|
||||
if (!first && !flipped && !big) return
|
||||
fittedGarden.current = garden.id
|
||||
// A focused bed (a ?focus= deep link, or a chrome flip mid-planting) frames
|
||||
// its bed rather than the whole garden.
|
||||
const f = useEditorStore.getState().focusId
|
||||
const target = f != null ? latest.current.byId.get(f) : undefined
|
||||
if (target) {
|
||||
fitSize.current = { w: size.w, h: size.h }
|
||||
focusObject(target)
|
||||
} else zoomFit()
|
||||
}, [size, garden.id, GW, GH, isMobile, zoomFit, focusObject])
|
||||
|
||||
// Wheel zooms to the cursor. Non-passive so the page doesn't scroll.
|
||||
useEffect(() => {
|
||||
const el = svgRef.current
|
||||
if (!el) return
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
if (!Number.isFinite(e.deltaY)) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||
const ns = clampScale(cur * Math.exp(-e.deltaY * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE)
|
||||
const px = e.clientX - r.left
|
||||
const py = e.clientY - r.top
|
||||
camera({ s: ns, tx: px - ((px - tx) / cur) * ns, ty: py - ((py - ty) / cur) * ns }, false)
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [camera])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (animTimer.current != null) window.clearTimeout(animTimer.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── pointer math ────────────────────────────────────────────────────────
|
||||
const world = (e: { clientX: number; clientY: number }): Point => {
|
||||
const r = svgRef.current!.getBoundingClientRect()
|
||||
const { tx, ty, s: cur } = useEditorStore.getState().vp
|
||||
return { x: (e.clientX - r.left - tx) / cur, y: (e.clientY - r.top - ty) / cur }
|
||||
}
|
||||
const snap = (v: number) => Math.round(v / latest.current.snapStep) * latest.current.snapStep
|
||||
const thresh = (e: { pointerType?: string }) => (e.pointerType === 'touch' ? 7 : 3)
|
||||
const track = (e: ReactPointerEvent) => {
|
||||
const r = svgRef.current!.getBoundingClientRect()
|
||||
pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
|
||||
try {
|
||||
svgRef.current!.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* not all pointers capture */
|
||||
}
|
||||
}
|
||||
const pinchStart = () => {
|
||||
const [a, b] = [...pts.current.values()]
|
||||
drag.current = null
|
||||
const { s: cur, tx, ty } = useEditorStore.getState().vp
|
||||
pinch.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), cx0: (a.x + b.x) / 2, cy0: (a.y + b.y) / 2, s0: cur, tx0: tx, ty0: ty }
|
||||
}
|
||||
|
||||
// ── placement ───────────────────────────────────────────────────────────
|
||||
const placeObject = (kind: string, w: Point) => {
|
||||
const def = kindDef(kind)
|
||||
const st = useEditorStore.getState()
|
||||
st.setArmedKind(null)
|
||||
st.setGhost(null)
|
||||
if (!def || !latest.current.canEdit) return
|
||||
const n = latest.current.rendered.filter((o) => o.kind === def.kind).length + 1
|
||||
createObject.mutate(
|
||||
{
|
||||
kind: def.kind,
|
||||
shape: def.shape,
|
||||
name: `${def.label} ${n}`,
|
||||
xCm: snap(w.x),
|
||||
yCm: snap(w.y),
|
||||
widthCm: def.widthCm,
|
||||
heightCm: def.heightCm,
|
||||
zIndex: def.defaultZ,
|
||||
plantable: def.plantable,
|
||||
},
|
||||
{ onSuccess: (o) => useEditorStore.getState().setSel({ type: 'object', id: o.id }) },
|
||||
)
|
||||
}
|
||||
|
||||
const placePlop = (o: EditorObject, local: Point, plant: Plant, lotId: number | null) => {
|
||||
if (!latest.current.canEdit || !o.plantable) return
|
||||
const r = defaultPlopRadius(plant)
|
||||
const c = clampPlopLocal(o, snapPlopLocal(o, local), r)
|
||||
createPlanting.mutate(
|
||||
{ objectId: o.id, plantId: plant.id, xCm: c.x, yCm: c.y, radiusCm: r, seedLotId: lotId ?? undefined },
|
||||
{
|
||||
onSuccess: (p) => {
|
||||
// Desktop selects what it just placed; the phone keeps the strip's plant
|
||||
// armed and the peek closed so the next tap plants again.
|
||||
if (!latest.current.isMobile) useEditorStore.getState().setSel({ type: 'plop', id: p.id })
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── pointer handlers ────────────────────────────────────────────────────
|
||||
const onCanvasDown = (e: ReactPointerEvent) => {
|
||||
track(e)
|
||||
if (pts.current.size === 2) return pinchStart()
|
||||
const st = useEditorStore.getState()
|
||||
const w = world(e)
|
||||
if (st.armedKind) return placeObject(st.armedKind, w)
|
||||
if (st.armedPlant) {
|
||||
const o = st.focusId != null ? latest.current.byId.get(st.focusId) : objectAt(latest.current.rendered, w)
|
||||
if (o?.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
|
||||
return
|
||||
}
|
||||
if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) }
|
||||
}
|
||||
|
||||
const objDown = (o: EditorObject) => (e: ReactPointerEvent) => {
|
||||
e.stopPropagation()
|
||||
track(e)
|
||||
if (pts.current.size === 2) return pinchStart()
|
||||
const st = useEditorStore.getState()
|
||||
const w = world(e)
|
||||
if (st.armedKind) return placeObject(st.armedKind, w)
|
||||
if (st.armedPlant) {
|
||||
if (o.plantable) placePlop(o, toLocal(o, w), st.armedPlant, st.armedLotId)
|
||||
return
|
||||
}
|
||||
// Dimmed siblings stay inert inside a focused bed.
|
||||
if (st.focusId != null && o.id !== st.focusId) return
|
||||
if (!latest.current.canEdit) {
|
||||
st.setSel({ type: 'object', id: o.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
drag.current = { t: 'obj', base: o, sx: w.x, sy: w.y, moved: false, th: thresh(e) }
|
||||
}
|
||||
|
||||
const plopDown = (p: EditorPlanting) => (e: ReactPointerEvent) => {
|
||||
const st = useEditorStore.getState()
|
||||
if (st.armedKind || st.armedPlant) return // bubbles to the bed / canvas, which places
|
||||
e.stopPropagation()
|
||||
const o = latest.current.byId.get(p.objectId)
|
||||
if (!o) return
|
||||
// Outside focus a plop is just part of its bed: grabbing it grabs the bed.
|
||||
if (st.focusId !== p.objectId) return objDown(o)(e)
|
||||
track(e)
|
||||
if (pts.current.size === 2) return pinchStart()
|
||||
if (!latest.current.canEdit) {
|
||||
st.setSel({ type: 'plop', id: p.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
const l = toLocal(o, world(e))
|
||||
drag.current = { t: 'plop', base: p, obj: o, sx: l.x, sy: l.y, moved: false, th: thresh(e) }
|
||||
}
|
||||
|
||||
const handleDown = (o: EditorObject, cx: number, cy: number) => (e: ReactPointerEvent) => {
|
||||
e.stopPropagation()
|
||||
track(e)
|
||||
if (!latest.current.canEdit) return
|
||||
// The corner opposite the dragged one stays put, in the object's own frame.
|
||||
drag.current = { t: 'resize', base: o, cx, cy, opp: { x: -cx * (o.widthCm / 2), y: -cy * (o.heightCm / 2) }, moved: false }
|
||||
}
|
||||
|
||||
const onCanvasMove = (e: ReactPointerEvent) => {
|
||||
const r = svgRef.current!.getBoundingClientRect()
|
||||
if (pts.current.has(e.pointerId)) pts.current.set(e.pointerId, { x: e.clientX - r.left, y: e.clientY - r.top })
|
||||
const st = useEditorStore.getState()
|
||||
if (pinch.current && pts.current.size >= 2) {
|
||||
const [a, b] = [...pts.current.values()]
|
||||
const p = pinch.current
|
||||
const d1 = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const cx = (a.x + b.x) / 2
|
||||
const cy = (a.y + b.y) / 2
|
||||
const ns = clampScale(p.s0 * (d1 / Math.max(1, p.d0)), MIN_SCALE, MAX_SCALE)
|
||||
const wx = (p.cx0 - p.tx0) / p.s0
|
||||
const wy = (p.cy0 - p.ty0) / p.s0
|
||||
camera({ s: ns, tx: cx - wx * ns, ty: cy - wy * ns }, false)
|
||||
return
|
||||
}
|
||||
const d = drag.current
|
||||
if (!d) {
|
||||
if (st.armedKind && e.pointerType !== 'touch') {
|
||||
const w = world(e)
|
||||
st.setGhost({ x: snap(w.x), y: snap(w.y) })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (d.t === 'pan') {
|
||||
if (Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > d.th) d.moved = true
|
||||
camera({ ...st.vp, tx: d.tx + e.clientX - d.sx, ty: d.ty + e.clientY - d.sy }, false)
|
||||
} else if (d.t === 'obj') {
|
||||
const w = world(e)
|
||||
if (Math.hypot(w.x - d.sx, w.y - d.sy) > d.th / st.vp.s) d.moved = true
|
||||
if (!d.moved) return
|
||||
st.setLiveObject({ ...d.base, xCm: snap(d.base.xCm + w.x - d.sx), yCm: snap(d.base.yCm + w.y - d.sy) })
|
||||
} else if (d.t === 'plop') {
|
||||
const l = toLocal(d.obj, world(e))
|
||||
if (Math.hypot(l.x - d.sx, l.y - d.sy) > d.th / st.vp.s) d.moved = true
|
||||
if (!d.moved) return
|
||||
const next = clampPlopLocal(
|
||||
d.obj,
|
||||
snapPlopLocal(d.obj, { x: d.base.xCm + l.x - d.sx, y: d.base.yCm + l.y - d.sy }),
|
||||
d.base.radiusCm,
|
||||
)
|
||||
st.setLivePlanting({ ...d.base, xCm: next.x, yCm: next.y })
|
||||
} else if (d.t === 'resize') {
|
||||
const p = toLocal(d.base, world(e))
|
||||
d.moved = true
|
||||
let newW = Math.max(MIN_OBJECT_CM, Math.abs(p.x - d.opp.x))
|
||||
let newH = Math.max(MIN_OBJECT_CM, Math.abs(p.y - d.opp.y))
|
||||
if (latest.current.garden.snapToGrid) {
|
||||
const g = latest.current.snapStep
|
||||
newW = Math.max(g, Math.round(newW / g) * g)
|
||||
newH = Math.max(g, Math.round(newH / g) * g)
|
||||
}
|
||||
const dragged = { x: d.opp.x + d.cx * newW, y: d.opp.y + d.cy * newH }
|
||||
const c = toWorld(d.base, { x: (d.opp.x + dragged.x) / 2, y: (d.opp.y + dragged.y) / 2 })
|
||||
st.setLiveObject({ ...d.base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH })
|
||||
}
|
||||
}
|
||||
|
||||
const onCanvasUp = (e: ReactPointerEvent) => {
|
||||
pts.current.delete(e.pointerId)
|
||||
if (pinch.current) {
|
||||
if (pts.current.size < 2) pinch.current = null
|
||||
return
|
||||
}
|
||||
const d = drag.current
|
||||
drag.current = null
|
||||
if (!d) return
|
||||
const st = useEditorStore.getState()
|
||||
if (d.t === 'pan') {
|
||||
if (!d.moved) st.setSel(null)
|
||||
return
|
||||
}
|
||||
if (d.t === 'obj') {
|
||||
if (!d.moved) {
|
||||
st.setSel({ type: 'object', id: d.base.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
const final = st.liveObject
|
||||
st.setLiveObject(null)
|
||||
if (final && (final.xCm !== d.base.xCm || final.yCm !== d.base.yCm))
|
||||
updateObject.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
|
||||
} else if (d.t === 'plop') {
|
||||
if (!d.moved) {
|
||||
st.setSel({ type: 'plop', id: d.base.id })
|
||||
st.setTab('plot')
|
||||
return
|
||||
}
|
||||
const final = st.livePlanting
|
||||
st.setLivePlanting(null)
|
||||
if (final) updatePlanting.mutate({ id: final.id, version: final.version, xCm: final.xCm, yCm: final.yCm })
|
||||
} else if (d.t === 'resize') {
|
||||
const final = st.liveObject
|
||||
st.setLiveObject(null)
|
||||
if (final && d.moved)
|
||||
updateObject.mutate({
|
||||
id: final.id,
|
||||
version: final.version,
|
||||
xCm: final.xCm,
|
||||
yCm: final.yCm,
|
||||
widthCm: final.widthCm,
|
||||
heightCm: final.heightCm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HTML5 drag-and-drop from the toolkit (desktop).
|
||||
const onDragOver = (e: DragEvent) => e.preventDefault()
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
const data = e.dataTransfer.getData('text/plain')
|
||||
if (!data) return
|
||||
const w = world(e)
|
||||
const [t, id] = data.split(':')
|
||||
if (t === 'kind') placeObject(id, w)
|
||||
if (t === 'plant' && dropPlant.current) {
|
||||
const o = objectAt(latest.current.rendered, w)
|
||||
if (o?.plantable) placePlop(o, toLocal(o, w), dropPlant.current.plant, dropPlant.current.lotId)
|
||||
}
|
||||
}
|
||||
// The plant being dragged from the toolkit rides along in the store (the
|
||||
// dataTransfer only carries its id); the armed plant is what's being dragged.
|
||||
dropPlant.current = armedPlant ? { plant: armedPlant, lotId: useEditorStore.getState().armedLotId } : null
|
||||
|
||||
// ── derived drawing data ───────────────────────────────────────────────
|
||||
const grid = useMemo(() => {
|
||||
const imperial = garden.unitPref === 'imperial'
|
||||
let step = imperial ? FT : 25
|
||||
const majorEvery = imperial ? 5 : 4
|
||||
while (Math.max(GW, GH) / step > 400) step *= majorEvery // a 100 m field still draws a sane number of lines
|
||||
const minor: string[] = []
|
||||
const major: string[] = []
|
||||
for (let i = 1; i * step < GW; i++) (i % majorEvery ? minor : major).push(`M${(i * step).toFixed(2)} 0V${GH}`)
|
||||
for (let i = 1; i * step < GH; i++) (i % majorEvery ? minor : major).push(`M0 ${(i * step).toFixed(2)}H${GW}`)
|
||||
return { minor: minor.join(''), major: major.join('') }
|
||||
}, [GW, GH, garden.unitPref])
|
||||
|
||||
const selectedObject = sel?.type === 'object' ? (byId.get(sel.id) ?? null) : null
|
||||
const showLabels = s > 0.28
|
||||
const handlePx = isMobile ? 14 : 9
|
||||
const cursor = armedKind || armedPlant ? 'crosshair' : 'default'
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className="absolute inset-0">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ display: 'block', touchAction: 'none', cursor, userSelect: 'none' }}
|
||||
onPointerDown={onCanvasDown}
|
||||
onPointerMove={onCanvasMove}
|
||||
onPointerUp={onCanvasUp}
|
||||
onPointerCancel={onCanvasUp}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
role="application"
|
||||
aria-label={`${garden.name} — the plan. Tap a bed to select it; double-click to plant it.`}
|
||||
>
|
||||
<title>{garden.name} plan</title>
|
||||
<g
|
||||
className={anim ? 'camera-anim' : undefined}
|
||||
style={{ transform: `translate(${vp.tx}px, ${vp.ty}px) scale(${s})`, transformOrigin: '0 0' }}
|
||||
>
|
||||
<rect x={0} y={0} width={GW} height={GH} rx={18} fill="var(--p-field)" stroke="var(--color-accent-2-500)" strokeWidth={3 / s} />
|
||||
<path d={grid.minor} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.06} fill="none" />
|
||||
<path d={grid.major} stroke="var(--p-grid-ink)" strokeWidth={1 / s} opacity={0.12} fill="none" />
|
||||
|
||||
{rendered.map((o) => {
|
||||
const st = objectStyle(o)
|
||||
const isSel = sel?.type === 'object' && sel.id === o.id
|
||||
const dim = focusId != null && o.id !== focusId
|
||||
const common = {
|
||||
fill: st.fill,
|
||||
stroke: isSel ? 'var(--color-accent)' : st.stroke,
|
||||
strokeWidth: (isSel ? 3.5 : 2.5) / s,
|
||||
strokeDasharray: st.dash,
|
||||
}
|
||||
return (
|
||||
<g
|
||||
key={o.id}
|
||||
data-object-id={o.id}
|
||||
transform={objectTransform(o)}
|
||||
opacity={dim ? DIM_OBJECT : 1}
|
||||
style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }}
|
||||
onPointerDown={objDown(o)}
|
||||
onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined}
|
||||
>
|
||||
{o.shape === 'circle' ? (
|
||||
<ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} />
|
||||
) : (
|
||||
<rect x={-o.widthCm / 2} y={-o.heightCm / 2} width={o.widthCm} height={o.heightCm} rx={rectRadius(o.widthCm)} {...common} />
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{renderedPlops.map((p) => {
|
||||
const o = byId.get(p.objectId)
|
||||
if (!o) return null
|
||||
const plant = plantsById.get(p.plantId)
|
||||
const w = toWorld(o, { x: p.xCm, y: p.yCm })
|
||||
const isSel = sel?.type === 'plop' && sel.id === p.id
|
||||
const inFocus = focusId === p.objectId
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
data-plop-id={p.id}
|
||||
transform={`translate(${w.x} ${w.y})`}
|
||||
opacity={focusId != null && !inFocus ? DIM_PLOP : 0.94}
|
||||
style={{ cursor: inFocus ? (canEdit ? 'grab' : 'pointer') : 'inherit' }}
|
||||
onPointerDown={plopDown(p)}
|
||||
>
|
||||
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
|
||||
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
|
||||
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Labels — semantic zoom. pointer-events none so they never catch a tap. */}
|
||||
<g style={{ pointerEvents: 'none' }}>
|
||||
{showLabels &&
|
||||
rendered.map((o) => {
|
||||
if (!o.name || Math.max(o.widthCm, o.heightCm) * s <= 54 || (focusId != null && focusId !== o.id)) return null
|
||||
const bh = rotatedHalfHeight(o)
|
||||
return o.plantable ? (
|
||||
<text
|
||||
key={`ol${o.id}`}
|
||||
x={o.xCm}
|
||||
y={o.yCm - bh - 9 / s}
|
||||
textAnchor="middle"
|
||||
fontSize={13 / s}
|
||||
fill="var(--p-ink-soft)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.02em' }}
|
||||
>
|
||||
{o.name}
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
key={`ol${o.id}`}
|
||||
x={o.xCm}
|
||||
y={o.yCm}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={13 / s}
|
||||
fill="var(--p-ink-mute)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 600, letterSpacing: '0.06em' }}
|
||||
>
|
||||
{o.name}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
{renderedPlops.map((p) => {
|
||||
const o = byId.get(p.objectId)
|
||||
if (!o || (focusId != null && p.objectId !== focusId)) return null
|
||||
const plant = plantsById.get(p.plantId)
|
||||
const r = p.radiusCm
|
||||
if (r * s < 9) return null
|
||||
const w = toWorld(o, { x: p.xCm, y: p.yCm })
|
||||
return (
|
||||
<g key={`pl${p.id}`}>
|
||||
<text
|
||||
x={w.x}
|
||||
y={w.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={r * 1.05}
|
||||
fill="var(--color-paper)"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
{letters.get(p.plantId) ?? '?'}
|
||||
</text>
|
||||
{r * s >= 34 && plant && (
|
||||
<text
|
||||
x={w.x}
|
||||
y={w.y + r + 13 / s}
|
||||
textAnchor="middle"
|
||||
fontSize={11 / s}
|
||||
fill="var(--p-ink-strong)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 600 }}
|
||||
>
|
||||
{plant.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{selectedObject && (
|
||||
<text
|
||||
x={selectedObject.xCm}
|
||||
y={selectedObject.yCm + rotatedHalfHeight(selectedObject) + 22 / s}
|
||||
textAnchor="middle"
|
||||
fontSize={12.5 / s}
|
||||
fill="var(--color-accent-700)"
|
||||
style={{ fontFamily: 'var(--font-body)', fontWeight: 700 }}
|
||||
>
|
||||
{formatSize(selectedObject.widthCm, selectedObject.heightCm, garden.unitPref)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Selection: a dashed accent outline offset from the object, and —
|
||||
for editors — corner handles to resize it. */}
|
||||
{selectedObject && (
|
||||
<g transform={objectTransform(selectedObject)}>
|
||||
{selectedObject.shape === 'circle' ? (
|
||||
<ellipse
|
||||
rx={selectedObject.widthCm / 2 + 7 / s}
|
||||
ry={selectedObject.heightCm / 2 + 7 / s}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1.8 / s}
|
||||
strokeDasharray={`${8 / s} ${6 / s}`}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={-selectedObject.widthCm / 2 - 7 / s}
|
||||
y={-selectedObject.heightCm / 2 - 7 / s}
|
||||
width={selectedObject.widthCm + 14 / s}
|
||||
height={selectedObject.heightCm + 14 / s}
|
||||
rx={16 / s}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1.8 / s}
|
||||
strokeDasharray={`${8 / s} ${6 / s}`}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
{canEdit &&
|
||||
!liveObject &&
|
||||
(
|
||||
[
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
] as const
|
||||
).map(([cx, cy]) => (
|
||||
<rect
|
||||
key={`${cx},${cy}`}
|
||||
x={cx * (selectedObject.widthCm / 2 + 7 / s) - handlePx / 2 / s}
|
||||
y={cy * (selectedObject.heightCm / 2 + 7 / s) - handlePx / 2 / s}
|
||||
width={handlePx / s}
|
||||
height={handlePx / s}
|
||||
rx={2 / s}
|
||||
fill="var(--color-neutral-100)"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1.5 / s}
|
||||
style={{ cursor: cx * cy > 0 ? 'nwse-resize' : 'nesw-resize' }}
|
||||
onPointerDown={handleDown(selectedObject, cx, cy)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{ghost && armedKind && (() => {
|
||||
const def = kindDef(armedKind)
|
||||
if (!def) return null
|
||||
const st = objectStyle({ kind: def.kind })
|
||||
return (
|
||||
<g transform={`translate(${ghost.x} ${ghost.y})`} opacity={0.55} style={{ pointerEvents: 'none' }}>
|
||||
{def.shape === 'circle' ? (
|
||||
<circle r={def.widthCm / 2} fill={st.fill} stroke="var(--color-accent)" strokeWidth={2 / s} strokeDasharray={`${7 / s} ${5 / s}`} />
|
||||
) : (
|
||||
<rect
|
||||
x={-def.widthCm / 2}
|
||||
y={-def.heightCm / 2}
|
||||
width={def.widthCm}
|
||||
height={def.heightCm}
|
||||
rx={12}
|
||||
fill={st.fill}
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2 / s}
|
||||
strokeDasharray={`${7 / s} ${5 / s}`}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,278 +0,0 @@
|
||||
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
describeStep,
|
||||
streamChat,
|
||||
useAgentHistory,
|
||||
useAgentRefresh,
|
||||
useClearAgentHistory,
|
||||
type AgentStep,
|
||||
type AgentTurn,
|
||||
} from '@/lib/agent'
|
||||
import { useUndo } from '@/lib/history'
|
||||
import { lazyPage } from '@/lib/lazyPage'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
|
||||
// assistant message actually renders, not for everyone who opens the editor.
|
||||
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
|
||||
// chunk 404 would otherwise permanently break the assistant.
|
||||
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
|
||||
|
||||
/**
|
||||
* Falls back to the raw message text if the markdown chunk can't load (a
|
||||
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
|
||||
* readable text, never take the whole editor down. Suspense handles the loading
|
||||
* phase; this handles the failure one.
|
||||
*/
|
||||
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talk to the garden assistant, in the editor beside the canvas.
|
||||
*
|
||||
* Here rather than on its own page because watching the garden change as the
|
||||
* agent works IS the confirmation — which is what makes acting without asking
|
||||
* first tolerable. It also means the agent never has to guess which garden you
|
||||
* mean.
|
||||
*/
|
||||
export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||
const history = useAgentHistory(gardenId, true)
|
||||
const clear = useClearAgentHistory(gardenId)
|
||||
const refresh = useAgentRefresh(gardenId)
|
||||
const undo = useUndo(gardenId)
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
// The turn in flight: what we sent, the steps so far, and how it ended.
|
||||
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [warning, setWarning] = useState<string | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
const bottom = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
|
||||
// rail to the inspector, so aborting here would mean clicking the canvas
|
||||
// mid-turn silently killed the turn — and the canvas is exactly what you're
|
||||
// meant to be watching. The request continues, the exchange is persisted
|
||||
// server-side, and coming back to this tab shows it. Only Stop aborts, and
|
||||
// even that only stops us READING: the turn keeps running server-side, which
|
||||
// is why its work still lands in History either way.
|
||||
|
||||
// Follow the conversation as it grows, including mid-turn as steps arrive.
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [history.data, pending])
|
||||
|
||||
const send = () => {
|
||||
const message = input.trim()
|
||||
if (!message || pending) return
|
||||
setInput('')
|
||||
setError(null)
|
||||
setWarning(null)
|
||||
setPending({ message, steps: [] })
|
||||
|
||||
const controller = new AbortController()
|
||||
abort.current = controller
|
||||
|
||||
void streamChat(
|
||||
gardenId,
|
||||
message,
|
||||
{
|
||||
onStep: (step) => {
|
||||
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||
// The canvas updating under the conversation is the whole point of
|
||||
// putting the chat here, so refresh it as each step lands — but only
|
||||
// it: nothing else can have changed until the turn commits.
|
||||
refresh.canvas()
|
||||
},
|
||||
onDone: (turn: AgentTurn) => {
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
if (turn.truncated) {
|
||||
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||
}
|
||||
},
|
||||
onWarning: setWarning,
|
||||
onError: (message) => {
|
||||
setPending(null)
|
||||
setError(message)
|
||||
// Something may still have landed before it failed.
|
||||
refresh.everything()
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
}
|
||||
|
||||
const messages = history.data ?? []
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-fg">Assistant</h2>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
clear.mutate(undefined, {
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||
})
|
||||
}
|
||||
disabled={clear.isPending || !!pending}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canEdit && (
|
||||
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">
|
||||
You can only view this garden, so the assistant can't change anything in it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
||||
{/* A failed load rendering as an empty thread would look like the
|
||||
conversation had been lost, which is a much worse thing to believe. */}
|
||||
{history.isError && (
|
||||
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
|
||||
)}
|
||||
|
||||
{history.isSuccess && messages.length === 0 && !pending && (
|
||||
<p className="text-sm text-muted">
|
||||
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
|
||||
north half of the west bed with beans”, “what's in here?”. Everything it does lands as one change you
|
||||
can undo.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{messages.map((m) => (
|
||||
<Bubble key={m.id} role={m.role} body={m.body}>
|
||||
{/* Undo on the turn itself, so the common case never involves
|
||||
opening the History panel. Same hook #49 uses, not a second
|
||||
implementation. */}
|
||||
{m.role === 'assistant' && m.changeSetId != null && canEdit && (
|
||||
<UndoButton changeSet={{ id: m.changeSetId }} undo={undo} className="mt-1 items-start" />
|
||||
)}
|
||||
</Bubble>
|
||||
))}
|
||||
|
||||
{pending && (
|
||||
<>
|
||||
<Bubble role="user" body={pending.message} />
|
||||
<div className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
||||
<ol className="flex flex-col gap-0.5">
|
||||
{pending.steps.map((s) => (
|
||||
<li key={s.index} className="text-xs text-muted">
|
||||
{describeStep(s)}…
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
||||
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{warning && <Alert tone="info">{warning}</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-2">
|
||||
<TextArea
|
||||
label="Message"
|
||||
name="agentMessage"
|
||||
rows={2}
|
||||
placeholder="Change the garlic bed to cucumbers this year"
|
||||
value={input}
|
||||
disabled={!!pending}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends, Shift+Enter breaks the line — the convention every
|
||||
// chat box uses, and typing a newline by accident mid-thought is a
|
||||
// worse failure than the reverse.
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{pending && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
abort.current?.abort()
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button className="px-3 py-1.5 text-sm" disabled={!!pending || input.trim() === ''} onClick={send}>
|
||||
{pending ? 'Working…' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({
|
||||
role,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
role: 'user' | 'assistant'
|
||||
body: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const mine = role === 'user'
|
||||
return (
|
||||
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-2.5 py-2 text-sm',
|
||||
// The user's own text is literal (their `*` shouldn't become a bullet)
|
||||
// and hugs the right; the assistant's Markdown is rendered and gets the
|
||||
// full width so a table has room.
|
||||
mine
|
||||
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
|
||||
: 'w-full border border-border text-fg',
|
||||
)}
|
||||
>
|
||||
{mine ? (
|
||||
body
|
||||
) : (
|
||||
// Show the raw text until the renderer chunk arrives (Suspense), and fall
|
||||
// back to it if the chunk can't load or the renderer throws (boundary) —
|
||||
// either way the message is readable, never blank and never a crash.
|
||||
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
|
||||
<MarkdownMessage>{body}</MarkdownMessage>
|
||||
</Suspense>
|
||||
</MarkdownBoundary>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { useClearObject } from '@/lib/objects'
|
||||
|
||||
/** Confirm clearing every active plop from a bed — a soft remove (the rows are
|
||||
* kept with removed_at, so history and past seasons survive), as one step. */
|
||||
export function ClearBedDialog({
|
||||
objectId,
|
||||
objectName,
|
||||
plopCount,
|
||||
gardenId,
|
||||
onClose,
|
||||
}: {
|
||||
objectId: number
|
||||
objectName: string
|
||||
plopCount: number
|
||||
gardenId: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const clear = useClearObject(gardenId)
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title={`Clear ${objectName}?`}
|
||||
confirmLabel="Clear it"
|
||||
busyLabel="Clearing…"
|
||||
confirmDisabled={plopCount === 0}
|
||||
errorFallback="Could not clear the bed."
|
||||
onConfirm={() => clear.mutateAsync(objectId)}
|
||||
onClose={onClose}
|
||||
>
|
||||
Pull all <span className="font-semibold text-text">{plopCount}</span> {plopCount === 1 ? 'planting' : 'plantings'} out
|
||||
of <span className="font-semibold text-text">{objectName}</span>. They stay in the journal and past seasons, and this
|
||||
is one undoable step.
|
||||
</ConfirmDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal'
|
||||
import { useClearObject } from '@/lib/objects'
|
||||
|
||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||
* are kept with removed_at, so history survives). */
|
||||
export function ClearBedModal({
|
||||
objectId,
|
||||
objectName,
|
||||
plopCount,
|
||||
gardenId,
|
||||
onClose,
|
||||
}: {
|
||||
objectId: number
|
||||
objectName: string
|
||||
plopCount: number
|
||||
gardenId: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const clear = useClearObject(gardenId)
|
||||
return (
|
||||
<ConfirmModal
|
||||
title="Clear bed"
|
||||
confirmLabel="Clear bed"
|
||||
busyLabel="Clearing…"
|
||||
confirmDisabled={plopCount === 0}
|
||||
errorFallback="Could not clear the bed."
|
||||
onConfirm={() => clear.mutateAsync(objectId)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/** A non-interactive hint overlaid on the canvas (empty-state guidance). */
|
||||
export function EditorHint({ position = 'center', children }: { position?: 'center' | 'top'; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute flex p-6 text-center',
|
||||
position === 'top' ? 'inset-x-0 top-16 justify-center' : 'inset-0 items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<p className="max-w-xs rounded-lg bg-surface/85 px-4 py-3 text-sm text-muted shadow-sm backdrop-blur">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* The editor's side rail, and the answer to "four things want one rail".
|
||||
*
|
||||
* The inspector, history, journal, and (later) the chat panel all want the same
|
||||
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
|
||||
* one rail — which keeps the canvas one width instead of a different
|
||||
* width per panel, and means adding the journal or chat is adding a tab.
|
||||
*
|
||||
* Two constraints shaped it:
|
||||
*
|
||||
* - Selecting an object must land you in the inspector with no extra click.
|
||||
* The editor watches the selection and switches to that tab itself, so the
|
||||
* rail never becomes a thing you have to operate before you can edit.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the rail
|
||||
* closes completely when nothing needs it.
|
||||
*
|
||||
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
|
||||
* Phone: an in-flow PEEK (#101) — a capped-height panel the editor's flex column
|
||||
* places BETWEEN the canvas and the always-visible mode bar, so the canvas
|
||||
* shrinks to keep the garden visible above it and the mode bar reachable below,
|
||||
* rather than a bottom sheet that covered the whole garden. `tall` raises that
|
||||
* cap for panel modes (journal/history/assistant), where reading and typing are
|
||||
* the task and a half-height peek felt cramped; the inspector keeps the shorter
|
||||
* peek so the canvas it describes stays in view.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Rendered lazily: only the active tab's content is built. */
|
||||
render: () => ReactNode
|
||||
/** Shown as a dot on the tab — a count, or true for a plain marker. */
|
||||
badge?: number | boolean
|
||||
}
|
||||
|
||||
export function EditorRail({
|
||||
tabs,
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
tall = false,
|
||||
}: {
|
||||
tabs: RailTab[]
|
||||
activeId: string
|
||||
onActivate: (id: string) => void
|
||||
onClose: () => void
|
||||
/** Raise the mobile peek's height cap (panel modes want the room). */
|
||||
tall?: boolean
|
||||
}) {
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: an in-flow PEEK — a capped-height panel that sits between the
|
||||
// canvas and the always-visible mode bar (the editor's flex column places
|
||||
// it there), so the garden stays visible above it and the mode bar stays
|
||||
// reachable below. The canvas flexes to fill whatever's left. Desktop: a
|
||||
// fixed-width column beside the canvas (the cap doesn't apply there).
|
||||
'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
|
||||
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
|
||||
// visible area and shove the mode bar off-screen (same #85 reasoning).
|
||||
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
|
||||
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onActivate(tab.id)}
|
||||
aria-current={tab.id === active.id ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
tab.id === active.id ? 'bg-border/60 text-fg' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge != null && tab.badge !== false && tab.badge !== 0 && (
|
||||
<span className="rounded-full bg-accent/20 px-1.5 text-[10px] font-semibold text-accent-strong">
|
||||
{tab.badge === true ? '•' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="ml-auto rounded px-1.5 text-sm text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import {
|
||||
screenToWorld,
|
||||
snapLocalToBedGrid,
|
||||
snapPoint,
|
||||
visibleGridStepCm,
|
||||
worldToLocal,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from '@/lib/geometry'
|
||||
import { formatCm, type UnitPref } from '@/lib/units'
|
||||
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { ObjectShape } from './ObjectShape'
|
||||
import { PlopLayer } from './PlopLayer'
|
||||
import { PlopOverlay } from './PlopOverlay'
|
||||
import { SelectionOverlay } from './SelectionOverlay'
|
||||
import { kindDef } from './kinds'
|
||||
import { DIMMED_OPACITY, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import { useViewport } from './useViewport'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
|
||||
const GRID_MIN_CELL_PX = 6
|
||||
const GRID_OPACITY = 0.18
|
||||
const OVERLAY_BADGE_CLASS = 'rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur'
|
||||
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
|
||||
|
||||
/**
|
||||
* What to tell the user about the grid they're looking at, or null when the
|
||||
* drawn lines are simply the garden's own grid and need no explanation. Two
|
||||
* states are worth naming rather than leaving them to infer: lines drawn every
|
||||
* Nth grid cell (the grid was too fine to draw at this zoom), and — degenerate —
|
||||
* a grid so fine no multiple of it is drawable, which matters most when snapping
|
||||
* is on and would otherwise be invisibly active (#47).
|
||||
*/
|
||||
function gridNoteFor(
|
||||
drawnCm: number | null,
|
||||
gridCm: number,
|
||||
snapToGrid: boolean,
|
||||
unit: UnitPref,
|
||||
): string | null {
|
||||
if (drawnCm == null) return snapToGrid ? 'grid too fine to draw — zoom in' : null
|
||||
if (drawnCm === gridCm) return null
|
||||
return `grid every ${formatCm(drawnCm, unit)}`
|
||||
}
|
||||
|
||||
/** The world-space bounds rect of an object (center + size → top-left rect). */
|
||||
function objectRect(o: EditorObject): Rect {
|
||||
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
|
||||
}
|
||||
|
||||
/** Grid-line offsets (from a bed's top-left corner, in the object-local frame)
|
||||
* for a bed of the given half-extents and grid step. Starts at the corner so the
|
||||
* lines match snapLocalToBedGrid, and is capped so a tiny grid on a big bed can't
|
||||
* emit thousands of lines. Returns cm offsets along one axis. */
|
||||
function bedGridLines(half: number, step: number): number[] {
|
||||
if (!(step > 0) || (2 * half) / step > BED_GRID_MAX_LINES) return []
|
||||
const lines: number[] = []
|
||||
for (let v = -half; v <= half + 1e-6; v += step) lines.push(v)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
|
||||
* rotate an object, and — the #15 core — focus into a plantable object to place,
|
||||
* move, resize and remove plops with semantic-zoom rendering. The object/plop
|
||||
* being dragged renders from liveObject/livePlanting for instant feedback; the
|
||||
* PATCH fires on release.
|
||||
*/
|
||||
export function GardenCanvas({
|
||||
garden,
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
canEdit,
|
||||
}: {
|
||||
garden: EditorGarden
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
|
||||
const fitKeyRef = useRef<string | null>(null)
|
||||
const gridId = 'garden-grid-' + useId().replace(/:/g, '')
|
||||
|
||||
const viewport = useEditorStore((s) => s.viewport)
|
||||
const selectedId = useEditorStore((s) => s.selectedId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
||||
const selectPlanting = useEditorStore((s) => s.selectPlanting)
|
||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const { fitToRect } = useViewport(svgRef)
|
||||
const createObject = useCreateObject(garden.id)
|
||||
const createPlanting = useCreatePlanting(garden.id)
|
||||
|
||||
const gardenRect: Rect = useMemo(
|
||||
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
|
||||
[garden.widthCm, garden.heightCm],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height }))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Single fit mechanism: frame the focused object, else the whole garden, and
|
||||
// animate whenever that target (or the garden) changes. The key guard stops a
|
||||
// refit on unrelated re-renders (a plop add, a bed drag).
|
||||
useEffect(() => {
|
||||
if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return
|
||||
const key = `${garden.id}:${focusedObjectId}`
|
||||
if (fitKeyRef.current === key) return
|
||||
const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined
|
||||
if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is
|
||||
fitKeyRef.current = key
|
||||
fitToRect(target ? objectRect(target) : gardenRect, size)
|
||||
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
|
||||
|
||||
// Draw the true grid when its cells are legible, else the coarsest-but-smallest
|
||||
// multiple of it that is (see visibleGridStepCm). Snapping still uses gridCm —
|
||||
// the drawn lines are a subset of the snap positions, never a different grid.
|
||||
const gridCm = garden.gridSizeCm
|
||||
const drawnGridCm = visibleGridStepCm(gridCm, viewport.scale, GRID_MIN_CELL_PX)
|
||||
const gridNote = gridNoteFor(drawnGridCm, gridCm, garden.snapToGrid, garden.unitPref)
|
||||
|
||||
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
|
||||
const rendered = useMemo(
|
||||
() => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
|
||||
[sorted, liveObject],
|
||||
)
|
||||
// Merge the in-flight live plop over the active list, same as liveObject.
|
||||
const renderedPlops = useMemo(
|
||||
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
|
||||
[plantings, livePlanting],
|
||||
)
|
||||
const selectedObject = rendered.find((o) => o.id === selectedId) ?? null
|
||||
const selectedPlop = renderedPlops.find((p) => p.id === selectedPlantingId) ?? null
|
||||
const selectedPlopObject = selectedPlop ? rendered.find((o) => o.id === selectedPlop.objectId) ?? null : null
|
||||
const focusedObject = focusedObjectId != null ? rendered.find((o) => o.id === focusedObjectId) ?? null : null
|
||||
|
||||
// A pointerdown reaching the svg is empty space: place the armed object kind,
|
||||
// or exit focus mode, or just deselect.
|
||||
function onCanvasPointerDown(e: ReactPointerEvent) {
|
||||
const armed = useEditorStore.getState().armedKind
|
||||
// Defense in depth: a viewer can't place objects even if a stale armed kind
|
||||
// slipped through (the palette isn't rendered for them).
|
||||
if (armed && canEdit) {
|
||||
useEditorStore.getState().setArmedKind(null)
|
||||
const def = kindDef(armed)
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
if (!def || !rect) return
|
||||
let world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||
// Snap the new object's center to the garden grid when the garden opts in.
|
||||
if (garden.snapToGrid) world = snapPoint(world, gridCm)
|
||||
createObject.mutate(
|
||||
{ kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ },
|
||||
{ onSuccess: (o) => select(o.id) },
|
||||
)
|
||||
return
|
||||
}
|
||||
// Empty-space tap while focused exits focus (and any placement); otherwise
|
||||
// just clears the selection.
|
||||
if (focusedObjectId != null) {
|
||||
setFocusedObject(null)
|
||||
useEditorStore.getState().setArmedPlant(null)
|
||||
}
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}
|
||||
|
||||
// Drop a plop where the user taps inside the focused object (placement stays
|
||||
// armed for repeat-placement until Escape / Done).
|
||||
function onPlace(e: ReactPointerEvent) {
|
||||
e.stopPropagation()
|
||||
if (!canEdit || !focusedObject || !armedPlant || !focusedObject.plantable) return
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||
const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
|
||||
// snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes
|
||||
// it a pure clamp, so both paths go through one helper.
|
||||
const { x, y } = snapLocalToBedGrid(
|
||||
local,
|
||||
focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0,
|
||||
focusedObject.widthCm / 2,
|
||||
focusedObject.heightCm / 2,
|
||||
)
|
||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||
// the object, so a selection would be hidden until placement ends anyway).
|
||||
createPlanting.mutate({
|
||||
objectId: focusedObject.id,
|
||||
plantId: armedPlant.id,
|
||||
xCm: x,
|
||||
yCm: y,
|
||||
radiusCm,
|
||||
seedLotId: armedLotId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
|
||||
|
||||
// Draw the bed's own grid inside a focused plantable bed that has snapping on,
|
||||
// so the user sees exactly where plants will land. Lines are in the bed's local
|
||||
// frame (origin at center), anchored to the corner to match snapLocalToBedGrid.
|
||||
// Memoized so a high-frequency plop drag (which re-renders the canvas on every
|
||||
// pointermove) doesn't rebuild the line arrays each frame. null when the focused
|
||||
// object isn't a snapping plantable bed.
|
||||
const bedGrid = useMemo(() => {
|
||||
if (!focusedObject || !focusedObject.plantable || !focusedObject.snapToGrid) return null
|
||||
return {
|
||||
v: bedGridLines(focusedObject.widthCm / 2, focusedObject.gridSizeCm),
|
||||
h: bedGridLines(focusedObject.heightCm / 2, focusedObject.gridSizeCm),
|
||||
}
|
||||
}, [focusedObject])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="h-full w-full select-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={onCanvasPointerDown}
|
||||
// role="application" tells a screen reader this is an interactive canvas
|
||||
// to operate, not a document to read linearly. The <title> names it, and
|
||||
// objects inside are individually focusable buttons (see ObjectShape).
|
||||
role="application"
|
||||
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
|
||||
>
|
||||
<title>{garden.name} garden layout</title>
|
||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||
{drawnGridCm != null && (
|
||||
<>
|
||||
<defs>
|
||||
<pattern id={gridId} width={drawnGridCm} height={drawnGridCm} patternUnits="userSpaceOnUse">
|
||||
<path d={`M ${drawnGridCm} 0 L 0 0 0 ${drawnGridCm}`} fill="none" stroke="#808080" strokeOpacity={GRID_OPACITY} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="none" stroke="#3f8f4f" strokeOpacity={0.7} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
|
||||
{rendered.map((o) => (
|
||||
<g key={o.id} opacity={focusedObjectId != null && focusedObjectId !== o.id ? DIMMED_OPACITY : 1}>
|
||||
<ObjectShape object={o} selected={o.id === selectedId} onSelect={select} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{focusedObject && bedGrid && (
|
||||
<g transform={objectTransform(focusedObject)} pointerEvents="none">
|
||||
{bedGrid.v.map((x) => (
|
||||
<line key={`v${x}`} x1={x} y1={-halfFH} x2={x} y2={halfFH} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{bedGrid.h.map((y) => (
|
||||
<line key={`h${y}`} x1={-halfFW} y1={y} x2={halfFW} y2={y} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
<PlopLayer
|
||||
objects={rendered}
|
||||
plantings={renderedPlops}
|
||||
plantsById={plantsById}
|
||||
scale={viewport.scale}
|
||||
focusedObjectId={focusedObjectId}
|
||||
selectedPlantingId={selectedPlantingId}
|
||||
onSelectPlop={selectPlanting}
|
||||
/>
|
||||
|
||||
{/* Edit handles are mounted only for editors/owners; viewers can still
|
||||
select to inspect (read-only), but never move/resize. */}
|
||||
{canEdit && selectedObject && (
|
||||
<SelectionOverlay
|
||||
object={selectedObject}
|
||||
gardenId={garden.id}
|
||||
svgRef={svgRef}
|
||||
snap={garden.snapToGrid}
|
||||
gridCm={gridCm}
|
||||
/>
|
||||
)}
|
||||
{canEdit && selectedPlop && selectedPlopObject && (
|
||||
<PlopOverlay
|
||||
plop={selectedPlop}
|
||||
object={selectedPlopObject}
|
||||
gardenId={garden.id}
|
||||
svgRef={svgRef}
|
||||
snap={selectedPlopObject.snapToGrid}
|
||||
gridCm={selectedPlopObject.gridSizeCm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placement capture: a transparent sheet over the focused object while a
|
||||
plant is armed, so taps drop plops instead of selecting the object. */}
|
||||
{canEdit && focusedObject && armedPlant && focusedObject.plantable && (
|
||||
<g transform={objectTransform(focusedObject)}>
|
||||
<rect
|
||||
x={-halfFW}
|
||||
y={-halfFH}
|
||||
width={halfFW * 2}
|
||||
height={halfFH * 2}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onPointerDown={onPlace}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="pointer-events-none absolute left-3 top-3 flex flex-col items-start gap-1">
|
||||
<span className={OVERLAY_BADGE_CLASS}>{viewport.scale.toFixed(2)} px/cm</span>
|
||||
{gridNote && <span className={OVERLAY_BADGE_CLASS}>{gridNote}</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
|
||||
className="absolute bottom-3 right-3 rounded-md border border-border bg-surface/90 px-3 py-1.5 text-sm font-medium text-fg shadow-sm outline-none backdrop-blur transition-colors hover:bg-border/50 focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { describeCounts, totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
/**
|
||||
* The garden's change history, newest first, with an undo on each entry.
|
||||
*
|
||||
* A reverted entry stays in the list, marked — and the revert appears as its own
|
||||
* entry, because that is what it is. Making the original disappear would be
|
||||
* rewriting history rather than adding to it, and would leave no way to undo the
|
||||
* undo.
|
||||
*/
|
||||
export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||
const history = useGardenHistory(gardenId)
|
||||
const undo = useUndo(gardenId)
|
||||
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-fg">History</h2>
|
||||
|
||||
{history.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{history.isError && sets.length === 0 && (
|
||||
<Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>
|
||||
)}
|
||||
|
||||
{history.isSuccess && sets.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
Nothing yet. Every change you make here shows up in this list, and can be undone from it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{sets.map((cs) => (
|
||||
<HistoryEntry key={cs.id} changeSet={cs} canEdit={canEdit} undo={undo} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{history.hasNextPage && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
{/* isError stays true for a failed page even though earlier pages
|
||||
loaded, so say so rather than leaving a button that did nothing. */}
|
||||
{history.isError && sets.length > 0 && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
{errorMessage(history.error, "Couldn't load older entries.")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Said plainly rather than discovered: deleting a garden bypasses this
|
||||
list entirely, because the delete cascades below the layer that records
|
||||
changes. Better to state the gap than to imply cover we don't have. */}
|
||||
<p className="border-t border-border pt-2 text-xs text-muted">
|
||||
Deleting a whole garden isn't covered here and can't be undone.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryEntry({
|
||||
changeSet,
|
||||
canEdit,
|
||||
undo,
|
||||
}: {
|
||||
changeSet: ChangeSet
|
||||
canEdit: boolean
|
||||
undo: ReturnType<typeof useUndo>
|
||||
}) {
|
||||
const counts = describeCounts(changeSet)
|
||||
const reverted = changeSet.revertedById != null
|
||||
const isRevert = changeSet.revertsId != null
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'rounded-lg border border-border px-2.5 py-2 text-sm',
|
||||
reverted && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-fg', reverted && 'line-through decoration-muted')}>{changeSet.summary}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
{changeSet.source === 'agent' && (
|
||||
<span className="rounded bg-accent/20 px-1 py-px font-medium uppercase tracking-wide text-accent-strong">
|
||||
agent
|
||||
</span>
|
||||
)}
|
||||
{isRevert && <span className="rounded bg-border/60 px-1 py-px font-medium">undo</span>}
|
||||
<span>{changeSet.actorName}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<time dateTime={changeSet.createdAt}>{relativeTime(changeSet.createdAt)}</time>
|
||||
{counts && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{counts}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{reverted && <p className="mt-1 text-xs text-muted">Undone</p>}
|
||||
</div>
|
||||
|
||||
{canEdit && totalChanges(changeSet) > 0 && (
|
||||
<UndoButton
|
||||
changeSet={changeSet}
|
||||
undo={undo}
|
||||
className="w-32 shrink-0 text-right"
|
||||
label={reverted ? 'Undo again' : 'Undo'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact "3m ago" / "yesterday" for a UTC timestamp. */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = (Date.now() - then) / 1000
|
||||
// Clock skew between the server and this browser can put a just-written entry
|
||||
// slightly in the future; that's "just now", not a negative duration.
|
||||
if (seconds < 60) return 'just now'
|
||||
// Floor throughout: 18 hours ago is "18h ago", not "yesterday". Rounding up
|
||||
// into the next unit reads as a bigger gap than actually elapsed.
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { totalChanges, type ChangeSet, type UndoOutcome } from '@/lib/history'
|
||||
import { relativeTime } from './shared'
|
||||
import type { useUndoLast } from './useUndoLast'
|
||||
|
||||
/**
|
||||
* Every change — yours or the assistant's — as one pill, newest first, with
|
||||
* Undo the last step at the bottom and an undo on any older step. A reverted
|
||||
* entry stays in the list, struck through, and the revert appears as its own
|
||||
* entry, because that is what it is: making the original disappear would be
|
||||
* rewriting history rather than adding to it.
|
||||
*/
|
||||
export function HistoryTab({ canEdit, undoLast }: { canEdit: boolean; undoLast: ReturnType<typeof useUndoLast> }) {
|
||||
const { history, sets, undo, canUndo, target } = undoLast
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||
<div className="text-[12.5px] leading-relaxed text-ink-mute">Every change — yours or the assistant's — is one undoable step.</div>
|
||||
{history.isPending && <p className="text-[13px] text-ink-mute">Loading…</p>}
|
||||
{history.isError && sets.length === 0 && <Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>}
|
||||
{history.isSuccess && sets.length === 0 && <div className="text-[13px] text-ink-mute">Nothing yet — go move something.</div>}
|
||||
{sets.map((cs) => (
|
||||
<Pill key={cs.id} cs={cs} canEdit={canEdit} outcome={undo.outcomeFor(cs.id)} onUndo={() => undo.undo(cs)} />
|
||||
))}
|
||||
{history.hasNextPage && (
|
||||
<Button variant="ghost" className="self-start text-[13px]" disabled={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Older steps'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-1.5">
|
||||
{canEdit && (
|
||||
<Button disabled={!canUndo} onClick={undoLast.undoLast} title={target ? `Undo: ${target.summary}` : undefined}>
|
||||
Undo the last step
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-[11px] leading-relaxed text-ink-mute">Deleting a whole garden isn't covered here and can't be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({ cs, canEdit, outcome, onUndo }: { cs: ChangeSet; canEdit: boolean; outcome?: UndoOutcome; onUndo: () => void }) {
|
||||
const reverted = cs.revertedById != null
|
||||
const isRevert = cs.revertsId != null
|
||||
const pending = outcome?.tone === 'pending'
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={cn('flex items-center gap-[9px] rounded-full border border-divider bg-bg py-2 pl-3.5 pr-2', reverted && 'opacity-60')}>
|
||||
<span className={cn('h-[7px] w-[7px] flex-none rounded-full', reverted ? 'bg-neutral-400' : isRevert ? 'bg-neutral-500' : cs.source === 'agent' ? 'bg-accent-2-500' : 'bg-accent-400')} />
|
||||
<span className={cn('min-w-0 truncate text-[13px] font-semibold', reverted && 'line-through')} title={`${cs.summary} — ${cs.actorName}${cs.source === 'agent' ? ' (assistant)' : ''}`}>
|
||||
{cs.summary}
|
||||
</span>
|
||||
<span className="ml-auto flex-none text-[11.5px] text-ink-mute">{relativeTime(cs.createdAt)}</span>
|
||||
{canEdit && !reverted && totalChanges(cs) > 0 && (
|
||||
<button type="button" className="btn btn-ghost -my-1 px-2 py-0.5 text-[11.5px]" disabled={pending} onClick={onUndo}>
|
||||
{pending ? 'Undoing…' : 'Undo'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{outcome && outcome.tone !== 'pending' && (
|
||||
<p role="status" className={cn('px-3.5 text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+394
-240
@@ -1,266 +1,242 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { ColorDot } from '@/components/plants/Monogram'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { Icon } from '@/components/ui/Icon'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import { Toggle } from '@/components/ui/Toggle'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import {
|
||||
cmFromSpacing,
|
||||
dimensionUnitLabel,
|
||||
dimensionInputMode,
|
||||
dimensionUnitLabel,
|
||||
formatDimensionInput,
|
||||
formatLength,
|
||||
formatSize,
|
||||
MIN_DIMENSION_CM,
|
||||
parseDimension,
|
||||
spacingFromCm,
|
||||
spacingUnitLabel,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
import { kindDef } from './kinds'
|
||||
import { kindDef, kindPlural } from './kinds'
|
||||
import { MIN_OBJECT_CM, plopCount } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_COLOR = '#8a8a8a'
|
||||
/** "Growing: 64 garlic · 3 tomato" for an object, or the empty line. */
|
||||
export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): string {
|
||||
const roster = new Map<string, number>()
|
||||
for (const p of plantings) {
|
||||
if (p.objectId !== o.id) continue
|
||||
const plant = plantsById.get(p.plantId)
|
||||
const name = (plant?.name ?? 'plants').toLowerCase()
|
||||
roster.set(name, (roster.get(name) ?? 0) + plopCount(p, plant))
|
||||
}
|
||||
if (roster.size === 0) return o.plantable ? 'Nothing planted yet.' : ''
|
||||
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
|
||||
}
|
||||
|
||||
/** A collapsible block of the less-often-needed fields. */
|
||||
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<button type="button" className="btn btn-ghost self-start gap-1.5 text-[13px]" onClick={onToggle} aria-expanded={open}>
|
||||
<Icon name="chevron-down" size={13} className={cn('transition-transform', open && 'rotate-180')} />
|
||||
Details
|
||||
</button>
|
||||
{open && <div className="flex flex-col gap-3 rounded-[18px] border border-divider bg-bg p-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property panel for the selected object. Each field commits a PATCH on
|
||||
* blur/change (carrying the object's version); dimensions and position are shown
|
||||
* in the garden's unit. Keyed by object id in the parent so it re-inits cleanly
|
||||
* on selection change.
|
||||
* The selected object: its name (edits on blur/Enter), kind + size, what's
|
||||
* growing in it, and the three actions — Plant this, rotate 90°, remove. The
|
||||
* details block below carries the exact geometry, color, plantability, the
|
||||
* bed grid and notes. Keyed by object id in the parent so fields re-init.
|
||||
*/
|
||||
export function Inspector({
|
||||
export function ObjectInspector({
|
||||
object,
|
||||
gardenId,
|
||||
unit,
|
||||
onFocus,
|
||||
onAddNote,
|
||||
noteCount = 0,
|
||||
readOnly = false,
|
||||
canEdit,
|
||||
focused,
|
||||
roster,
|
||||
noteCount,
|
||||
large,
|
||||
onPlantThis,
|
||||
onNotes,
|
||||
onDeleted,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
onFocus?: () => void
|
||||
/** Opens the journal scoped to this object. Two taps from a selected bed to
|
||||
* typing is the bar; anything more and the log stays empty. */
|
||||
onAddNote?: () => void
|
||||
/** How many journal entries are about this object, so the log is discoverable
|
||||
* from the thing it's about rather than being a panel you have to remember. */
|
||||
noteCount?: number
|
||||
readOnly?: boolean
|
||||
canEdit: boolean
|
||||
/** Already inside this bed (so "Plant this" is redundant). */
|
||||
focused: boolean
|
||||
roster: string
|
||||
noteCount: number
|
||||
/** Phone: 16px inputs, 44px targets. */
|
||||
large?: boolean
|
||||
onPlantThis: () => void
|
||||
onNotes: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const update = useUpdateObject(gardenId)
|
||||
const del = useDeleteObject(gardenId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
|
||||
// Local field state (initialized once; committed on blur/change).
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [name, setName] = useState(object.name)
|
||||
const [notes, setNotes] = useState(object.notes)
|
||||
const [details, setDetails] = useState(false)
|
||||
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
|
||||
const [height, setHeight] = useState(formatDimensionInput(object.heightCm, unit))
|
||||
const [x, setX] = useState(formatDimensionInput(object.xCm, unit))
|
||||
const [y, setY] = useState(formatDimensionInput(object.yCm, unit))
|
||||
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
|
||||
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
|
||||
const [gridSize, setGridSize] = useState(String(spacingFromCm(object.gridSizeCm, unit)))
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [notes, setNotes] = useState(object.notes)
|
||||
|
||||
// When the object changes underneath us (e.g. a canvas move/resize/rotate, or
|
||||
// an optimistic PATCH result), re-sync the fields — unless the user is
|
||||
// actively editing one here, so we don't clobber their typing.
|
||||
// Re-sync when the object changes underneath (a drag, a server row) unless a
|
||||
// field here is being edited.
|
||||
useEffect(() => {
|
||||
if (rootRef.current?.contains(document.activeElement)) return
|
||||
setName(object.name)
|
||||
setNotes(object.notes)
|
||||
setWidth(formatDimensionInput(object.widthCm, unit))
|
||||
setHeight(formatDimensionInput(object.heightCm, unit))
|
||||
setX(formatDimensionInput(object.xCm, unit))
|
||||
setY(formatDimensionInput(object.yCm, unit))
|
||||
setRotation(String(Math.round(object.rotationDeg)))
|
||||
setColor(object.color ?? DEFAULT_COLOR)
|
||||
setGridSize(String(spacingFromCm(object.gridSizeCm, unit)))
|
||||
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, object.gridSizeCm, unit])
|
||||
setNotes(object.notes)
|
||||
}, [object, unit])
|
||||
|
||||
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => {
|
||||
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
|
||||
if (!canEdit) return
|
||||
update.mutate({ id: object.id, version: object.version, ...fields })
|
||||
}
|
||||
|
||||
// Commit a dimension/position field. `positive` gates width/height (must be
|
||||
// ≥ the server minimum) but not x/y, which may be zero or negative.
|
||||
//
|
||||
// The no-op guard compares the field's TEXT against the string this field
|
||||
// renders for the current value. With compound entry there is no single
|
||||
// display number left to compare, and a numeric tolerance would be worse:
|
||||
// a bed dragged to some arbitrary cm renders as, say, 2′ 7.9″, and merely
|
||||
// tabbing through the field would snap it to a whole inch. Text equality means
|
||||
// "you didn't edit this", which is what the guard is actually for. Typing the
|
||||
// same value a different way (2' 7" vs 2′ 7″) falls through to the cm compare
|
||||
// below and is still a no-op.
|
||||
const commitName = () => {
|
||||
const v = name.trim()
|
||||
if (v && v !== object.name) patch({ name: v })
|
||||
else setName(object.name)
|
||||
}
|
||||
const commitDim = (raw: string, current: number, apply: (cm: number) => void, positive = false) => {
|
||||
if (raw.trim() === formatDimensionInput(current, unit)) return
|
||||
const cm = parseDimension(raw, unit)
|
||||
if (cm === null) return // unparseable: leave the row alone rather than commit a zero
|
||||
if (positive && cm < MIN_DIMENSION_CM) return
|
||||
if (cm === null) return
|
||||
if (positive && cm < Math.max(MIN_DIMENSION_CM, MIN_OBJECT_CM)) return
|
||||
if (cm !== current) apply(cm)
|
||||
}
|
||||
|
||||
// Commit the bed grid size (entered at spacing scale, cm/in). Compare at display
|
||||
// precision so a blur without an edit doesn't fire a spurious PATCH; ignore a
|
||||
// sub-1cm value the server would reject.
|
||||
const commitGrid = () => {
|
||||
const v = parseFloat(gridSize)
|
||||
if (!Number.isFinite(v)) return
|
||||
if (v === spacingFromCm(object.gridSizeCm, unit)) return
|
||||
if (!Number.isFinite(v) || v === spacingFromCm(object.gridSizeCm, unit)) return
|
||||
const cm = cmFromSpacing(v, unit)
|
||||
if (cm >= MIN_DIMENSION_CM && cm !== object.gridSizeCm) patch({ gridSizeCm: cm })
|
||||
}
|
||||
|
||||
const u = dimensionUnitLabel(unit)
|
||||
const inputMode = dimensionInputMode(unit)
|
||||
const inputCls = cn(large && 'input-lg')
|
||||
const btnSize = large ? 44 : 36
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg">{kindDef(object.kind)?.label ?? object.kind}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(null)}
|
||||
className="rounded px-1.5 text-sm text-muted hover:text-fg"
|
||||
aria-label="Close inspector"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<input
|
||||
className={cn('input font-bold', inputCls)}
|
||||
aria-label="Name"
|
||||
value={name}
|
||||
readOnly={!canEdit}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="neutral">{kindDef(object.kind)?.label ?? object.kind}</Tag>
|
||||
<span className="text-[13px] font-semibold text-ink-soft">{formatSize(object.widthCm, object.heightCm, unit)}</span>
|
||||
{noteCount > 0 && (
|
||||
<button type="button" className="tag tag-accent-2 cursor-pointer border-0" onClick={onNotes}>
|
||||
{noteCount} {noteCount === 1 ? 'note' : 'notes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">View only — you can't edit this garden.</p>
|
||||
)}
|
||||
|
||||
{!readOnly && object.plantable && onFocus && (
|
||||
<Button onClick={onFocus} className="w-full">
|
||||
🌱 Plant here
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onAddNote && (
|
||||
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
|
||||
{noteCount > 0
|
||||
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
|
||||
: readOnly
|
||||
? '📝 No notes'
|
||||
: '📝 Add note'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* A disabled fieldset makes every control below read-only for viewers in
|
||||
one shot (no per-input disabled). */}
|
||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => name !== object.name && patch({ name })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Width (${u})`}
|
||||
name="width"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Height (${u})`}
|
||||
name="height"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
|
||||
/>
|
||||
<TextField
|
||||
label={`X (${u})`}
|
||||
name="x"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={x}
|
||||
onChange={(e) => setX(e.target.value)}
|
||||
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
||||
/>
|
||||
<TextField
|
||||
label={`Y (${u})`}
|
||||
name="y"
|
||||
type="text"
|
||||
inputMode={inputMode}
|
||||
value={y}
|
||||
onChange={(e) => setY(e.target.value)}
|
||||
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Rotation (°)"
|
||||
name="rotation"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(rotation)
|
||||
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="obj-color" className="text-sm font-medium text-fg">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="obj-color"
|
||||
type="color"
|
||||
value={color}
|
||||
// onChange fires continuously while dragging in the native picker;
|
||||
// track it locally for the live swatch and commit one PATCH on blur.
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setColor(e.target.value)}
|
||||
onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
|
||||
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
|
||||
/>
|
||||
</div>
|
||||
{object.color && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setColor(DEFAULT_COLOR)
|
||||
patch({ color: null })
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
{roster && <div className="text-[12.5px] leading-relaxed text-ink-soft">{roster}</div>}
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
{object.plantable && !focused && (
|
||||
<Button variant="primary" className="flex-1" tall={large} onClick={onPlantThis}>
|
||||
Plant this
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.plantable}
|
||||
onChange={(e) => patch({ plantable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
<IconButton label="Rotate 90°" icon="rotate-cw" iconSize={large ? 16 : 15} size={btnSize} onClick={() => patch({ rotationDeg: (object.rotationDeg + 90) % 360 })} />
|
||||
<IconButton
|
||||
label="Remove"
|
||||
icon="trash-2"
|
||||
iconSize={large ? 16 : 15}
|
||||
size={btnSize}
|
||||
iconClassName="text-accent-700"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
onDeleted()
|
||||
del.mutate(object.id)
|
||||
}}
|
||||
/>
|
||||
Plantable
|
||||
</label>
|
||||
{!object.plantable || focused ? <span className="flex-1" /> : null}
|
||||
</div>
|
||||
)}
|
||||
{noteCount === 0 && (
|
||||
<button type="button" className="btn btn-ghost self-start gap-1.5 text-[13px]" onClick={onNotes}>
|
||||
<Icon name="notebook" size={13} />
|
||||
{canEdit ? 'Add a note' : 'Notes'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bed grid: only plantable beds place plants, so the plant-snapping grid
|
||||
is shown just for them. */}
|
||||
{object.plantable && (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Details open={details} onToggle={() => setDetails((v) => !v)}>
|
||||
<fieldset disabled={!canEdit} className="contents">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField label={`Width (${u})`} name="width" type="text" inputMode={inputMode} className={inputCls} value={width} onChange={(e) => setWidth(e.target.value)} onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)} />
|
||||
<TextField label={`Height (${u})`} name="height" type="text" inputMode={inputMode} className={inputCls} value={height} onChange={(e) => setHeight(e.target.value)} onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)} />
|
||||
<TextField label={`X (${u})`} name="x" type="text" inputMode={inputMode} className={inputCls} value={x} onChange={(e) => setX(e.target.value)} onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))} />
|
||||
<TextField label={`Y (${u})`} name="y" type="text" inputMode={inputMode} className={inputCls} value={y} onChange={(e) => setY(e.target.value)} onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))} />
|
||||
<TextField
|
||||
label="Rotation (°)"
|
||||
name="rotation"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
className={inputCls}
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(rotation)
|
||||
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: ((v % 360) + 360) % 360 })
|
||||
}}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor={`color-${object.id}`}>Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`color-${object.id}`}
|
||||
type="color"
|
||||
value={object.color ?? '#e3d0ac'}
|
||||
onChange={(e) => patch({ color: e.target.value })}
|
||||
className="h-9 w-12 cursor-pointer rounded-full border border-divider bg-surface p-1"
|
||||
/>
|
||||
{object.color && (
|
||||
<button type="button" className="btn btn-ghost px-2 text-xs" onClick={() => patch({ color: null })}>
|
||||
Kind's own
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-semibold">Plantable</span>
|
||||
<Toggle className="ml-auto" on={object.plantable} label="Plantable" disabled={!canEdit} onChange={(v) => patch({ plantable: v })} />
|
||||
</div>
|
||||
{object.plantable && (
|
||||
<div className="flex items-end gap-2">
|
||||
<TextField
|
||||
label={`Bed grid (${spacingUnitLabel(unit)})`}
|
||||
name="gridSize"
|
||||
@@ -268,56 +244,234 @@ export function Inspector({
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
className={inputCls}
|
||||
wrapperClassName="flex-1"
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(e.target.value)}
|
||||
onBlur={commitGrid}
|
||||
/>
|
||||
<div className="field">
|
||||
<label>Snap plants</label>
|
||||
<Toggle on={object.snapToGrid} label="Snap plants to the bed grid" disabled={!canEdit} onChange={(v) => patch({ snapToGrid: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.snapToGrid}
|
||||
onChange={(e) => patch({ snapToGrid: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
Snap plants
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextArea
|
||||
label="Notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{!readOnly &&
|
||||
(confirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
select(null)
|
||||
del.mutate(object.id)
|
||||
}}
|
||||
>
|
||||
Confirm delete
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" className="text-red-600 dark:text-red-400" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete object
|
||||
</Button>
|
||||
))}
|
||||
)}
|
||||
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
|
||||
</fieldset>
|
||||
</Details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected planting: its plant, "N plants · 6″ patch", and Pull it out.
|
||||
* Details hold the radius, a count override, a label, the planted date, and
|
||||
* the plant itself (swap it for another without re-placing).
|
||||
*/
|
||||
export function PlopInspector({
|
||||
plop,
|
||||
plant,
|
||||
plants,
|
||||
gardenId,
|
||||
unit,
|
||||
canEdit,
|
||||
noteCount,
|
||||
large,
|
||||
onNotes,
|
||||
onRemoved,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant: Plant | undefined
|
||||
plants: Plant[]
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
canEdit: boolean
|
||||
noteCount: number
|
||||
large?: boolean
|
||||
onNotes: () => void
|
||||
onRemoved: () => void
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
const remove = useRemovePlanting(gardenId)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const p = livePlanting && livePlanting.id === plop.id ? livePlanting : plop
|
||||
const [details, setDetails] = useState(false)
|
||||
const [radius, setRadius] = useState(String(spacingFromCm(p.radiusCm, unit)))
|
||||
const [count, setCount] = useState(p.count != null ? String(p.count) : '')
|
||||
const [label, setLabel] = useState(p.label ?? '')
|
||||
const [planted, setPlanted] = useState(p.plantedAt ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (rootRef.current?.contains(document.activeElement)) return
|
||||
setRadius(String(spacingFromCm(p.radiusCm, unit)))
|
||||
setCount(p.count != null ? String(p.count) : '')
|
||||
setLabel(p.label ?? '')
|
||||
setPlanted(p.plantedAt ?? '')
|
||||
}, [p, unit])
|
||||
|
||||
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) => {
|
||||
if (!canEdit) return
|
||||
update.mutate({ id: plop.id, version: plop.version, ...fields })
|
||||
}
|
||||
const n = plopCount(p, plant)
|
||||
const inputCls = cn(large && 'input-lg')
|
||||
const sorted = useMemo(() => [...plants].sort((a, b) => a.name.localeCompare(b.name)), [plants])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ColorDot color={plant?.color ?? '#97a97c'} size={18} />
|
||||
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
|
||||
{noteCount > 0 && (
|
||||
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
|
||||
{noteCount} {noteCount === 1 ? 'note' : 'notes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[13px] text-ink-soft">
|
||||
{n} {n === 1 ? 'plant' : 'plants'} · {formatLength(p.radiusCm * 2, unit)} patch
|
||||
{p.label ? ` · ${p.label}` : ''}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button
|
||||
tall={large}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onRemoved()
|
||||
remove.mutate({ id: plop.id, version: plop.version })
|
||||
}}
|
||||
>
|
||||
Pull it out
|
||||
</Button>
|
||||
)}
|
||||
{noteCount === 0 && (
|
||||
<button type="button" className="btn btn-ghost gap-1.5 text-[13px]" onClick={onNotes}>
|
||||
<Icon name="notebook" size={13} />
|
||||
{canEdit ? 'Add a note' : 'Notes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Details open={details} onToggle={() => setDetails((v) => !v)}>
|
||||
<fieldset disabled={!canEdit} className="contents">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Radius (${spacingUnitLabel(unit)})`}
|
||||
name="radius"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
className={inputCls}
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = Number(radius)
|
||||
if (radius.trim() === '' || !Number.isFinite(v)) return setRadius(String(spacingFromCm(p.radiusCm, unit)))
|
||||
const cm = Math.max(1, cmFromSpacing(v, unit))
|
||||
if (cm !== p.radiusCm) patch({ radiusCm: cm })
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Count"
|
||||
name="count"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
className={inputCls}
|
||||
placeholder={`${plant ? plopCount({ ...p, count: null }, plant) : p.derivedCount} (auto)`}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (count.trim() === '') return p.count != null ? patch({ count: null }) : undefined
|
||||
const c = Number(count)
|
||||
if (!Number.isInteger(c) || c < 1) return setCount(p.count != null ? String(p.count) : '')
|
||||
if (c !== p.count) patch({ count: c })
|
||||
}}
|
||||
hint={p.count != null ? 'An override — clear it for auto.' : 'Auto from area ÷ spacing².'}
|
||||
/>
|
||||
</div>
|
||||
<TextField label="Label (optional)" name="label" className={inputCls} value={label} onChange={(e) => setLabel(e.target.value)} onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })} />
|
||||
<TextField
|
||||
label="Planted"
|
||||
name="planted"
|
||||
type="date"
|
||||
className={inputCls}
|
||||
value={planted}
|
||||
onChange={(e) => setPlanted(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = planted === '' ? null : planted
|
||||
if (next !== (p.plantedAt ?? null)) patch({ plantedAt: next })
|
||||
}}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor={`plant-${plop.id}`}>Plant</label>
|
||||
<select id={`plant-${plop.id}`} className={cn('input', inputCls)} value={plop.plantId} onChange={(e) => patch({ plantId: Number(e.target.value) })}>
|
||||
{!sorted.some((x) => x.id === plop.plantId) && <option value={plop.plantId}>{plant?.name ?? 'Unknown plant'}</option>}
|
||||
{sorted.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The rail's resting state: what the whole garden holds. */
|
||||
export function GardenSummary({
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
canEdit,
|
||||
}: {
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
canEdit: boolean
|
||||
}) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const o of objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
|
||||
const countsText = [
|
||||
...['bed', 'grow_bag', 'container', 'in_ground', 'tree'].filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!)),
|
||||
`${plantings.length} ${plantings.length === 1 ? 'planting' : 'plantings'}`,
|
||||
].join(' · ')
|
||||
const roster = new Map<number, { name: string; color: string; n: number; beds: string[] }>()
|
||||
const byId = new Map(objects.map((o) => [o.id, o]))
|
||||
for (const p of plantings) {
|
||||
const o = byId.get(p.objectId)
|
||||
const plant = plantsById.get(p.plantId)
|
||||
if (!o || !plant) continue
|
||||
const r = roster.get(plant.id) ?? { name: plant.name, color: plant.color, n: 0, beds: [] }
|
||||
r.n += plopCount(p, plant)
|
||||
if (!r.beds.includes(o.name)) r.beds.push(o.name)
|
||||
roster.set(plant.id, r)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-0.5">This garden</h5>
|
||||
<div className="text-[13px] leading-relaxed text-ink-soft">{objects.length === 0 ? 'Bare ground so far.' : countsText}</div>
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
{[...roster.values()].map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-[9px]">
|
||||
<ColorDot color={r.color} size={13} />
|
||||
<span className="min-w-0 truncate text-[13px] font-semibold">{r.name}</span>
|
||||
<span className="ml-auto flex-none text-xs text-ink-mute">
|
||||
{r.n} in {r.beds[0]}
|
||||
{r.beds.length > 1 ? ` +${r.beds.length - 1}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto pt-2.5 text-xs leading-relaxed text-ink-mute">
|
||||
{canEdit ? 'Select anything on the plan to edit it here. Double-click a bed to plant it.' : 'Select anything on the plan to see it here.'}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
formatObservedAt,
|
||||
today,
|
||||
useCreateJournalEntry,
|
||||
useDeleteJournalEntry,
|
||||
useJournal,
|
||||
useUpdateJournalEntry,
|
||||
type JournalEntry,
|
||||
} from '@/lib/journal'
|
||||
import type { EditorObject } from './types'
|
||||
import { objectDisplayName } from './kinds'
|
||||
|
||||
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||
// don't drift from each other.
|
||||
const dateInputClass =
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
|
||||
/**
|
||||
* The garden's journal: write an entry, read the season back.
|
||||
*
|
||||
* Notes get written standing in the garden holding a phone, usually one-handed.
|
||||
* If it takes more than a couple of taps from looking at a bed to typing a
|
||||
* sentence, the log stays empty — so the composer is open by default rather than
|
||||
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
|
||||
*/
|
||||
export function JournalPanel({
|
||||
gardenId,
|
||||
canEdit,
|
||||
currentUserId,
|
||||
isOwner,
|
||||
objects,
|
||||
scopeObjectId,
|
||||
onScopeChange,
|
||||
scopePlantingId,
|
||||
onScopePlantingChange,
|
||||
}: {
|
||||
gardenId: number
|
||||
canEdit: boolean
|
||||
currentUserId?: number
|
||||
isOwner: boolean
|
||||
objects: EditorObject[]
|
||||
/** Which bed the panel is filtered to, if any. */
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
/** Which single plop the panel is filtered to, if any (#85). The store keeps
|
||||
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
|
||||
scopePlantingId: number | null
|
||||
onScopePlantingChange: (id: number | null) => void
|
||||
}) {
|
||||
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||
// from/to; they just had no UI. Empty inputs don't filter.
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
// One source of scope priority — plop over bed — for both the filter and the
|
||||
// composer's label, so they can't drift.
|
||||
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
|
||||
const filter = {
|
||||
...(scopePlantingId != null
|
||||
? { plantingId: scopePlantingId }
|
||||
: scopeObjectId != null
|
||||
? { objectId: scopeObjectId }
|
||||
: {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-fg">Journal</h2>
|
||||
{objects.length > 0 && (
|
||||
<select
|
||||
value={scopeObjectId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value)
|
||||
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
|
||||
}}
|
||||
className="max-w-[9rem] truncate rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<option value="">Whole garden</option>
|
||||
{objects.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{objectDisplayName(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scopePlantingId != null && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
|
||||
<span className="text-accent-strong">Notes about one planting</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onScopePlantingChange(null)}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
}}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
objectId={scopePlantingId != null ? null : scopeObjectId}
|
||||
plantingId={scopePlantingId}
|
||||
scopeLabel={scopeLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{journal.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{journal.isError && entries.length === 0 && (
|
||||
<Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>
|
||||
)}
|
||||
|
||||
{journal.isSuccess && entries.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
{scopePlantingId != null
|
||||
? 'Nothing written about this planting yet.'
|
||||
: scopedObject
|
||||
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
|
||||
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{entries.map((e) => (
|
||||
<Entry
|
||||
key={e.id}
|
||||
entry={e}
|
||||
gardenId={gardenId}
|
||||
objects={objects}
|
||||
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
|
||||
canRewrite={canEdit && e.authorId === currentUserId}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{journal.hasNextPage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={journal.isFetchingNextPage}
|
||||
onClick={() => void journal.fetchNextPage()}
|
||||
>
|
||||
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
)}
|
||||
{journal.isError && entries.length > 0 && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
{errorMessage(journal.error, "Couldn't load older entries.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Composer({
|
||||
gardenId,
|
||||
objectId,
|
||||
plantingId = null,
|
||||
scopeLabel,
|
||||
}: {
|
||||
gardenId: number
|
||||
objectId: number | null
|
||||
/** When set, the note attaches to this plop rather than a bed (#85). */
|
||||
plantingId?: number | null
|
||||
scopeLabel: string | null
|
||||
}) {
|
||||
const create = useCreateJournalEntry(gardenId)
|
||||
const [body, setBody] = useState('')
|
||||
// Editable so an observation can be backdated: you write up Saturday on Sunday.
|
||||
const [observedAt, setObservedAt] = useState(today())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const submit = () => {
|
||||
const text = body.trim()
|
||||
if (!text) return
|
||||
setError(null)
|
||||
create.mutate(
|
||||
{ body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setBody('')
|
||||
setObservedAt(today())
|
||||
},
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
|
||||
<TextArea
|
||||
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
|
||||
name="journalBody"
|
||||
rows={3}
|
||||
placeholder="What happened?"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
label="Observed"
|
||||
name="observedAt"
|
||||
type="date"
|
||||
value={observedAt}
|
||||
onChange={(e) => setObservedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
|
||||
{create.isPending ? 'Saving…' : 'Save note'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Entry({
|
||||
entry,
|
||||
gardenId,
|
||||
objects,
|
||||
canDelete,
|
||||
canRewrite,
|
||||
}: {
|
||||
entry: JournalEntry
|
||||
gardenId: number
|
||||
objects: EditorObject[]
|
||||
/** May remove it: the author, or the garden owner. */
|
||||
canDelete: boolean
|
||||
/** May rewrite the text: the author only — rewriting someone else's
|
||||
* observation under their name is a different act from removing it. */
|
||||
canRewrite: boolean
|
||||
}) {
|
||||
const update = useUpdateJournalEntry(gardenId)
|
||||
const del = useDeleteJournalEntry(gardenId)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(entry.body)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const about = objects.find((o) => o.id === entry.objectId) ?? null
|
||||
|
||||
// Re-sync the draft when the entry changes underneath — a refetch after
|
||||
// someone else's edit, or this entry's own successful save. Skipped while
|
||||
// editing so a background refetch can't clobber what's being typed; the
|
||||
// version guard is what catches a genuine collision.
|
||||
const [syncedBody, setSyncedBody] = useState(entry.body)
|
||||
if (!editing && entry.body !== syncedBody) {
|
||||
setSyncedBody(entry.body)
|
||||
setDraft(entry.body)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const text = draft.trim()
|
||||
if (!text) {
|
||||
// Silence here reads as a broken button; say why nothing happened.
|
||||
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
update.mutate(
|
||||
{ id: entry.id, version: entry.version, body: text },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
},
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setDraft(entry.body)
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
|
||||
{update.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{entry.authorName}</span>
|
||||
{about && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
|
||||
</>
|
||||
)}
|
||||
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
|
||||
</p>
|
||||
|
||||
{(canRewrite || canDelete) && !editing && (
|
||||
<div className="mt-1 flex justify-end gap-1">
|
||||
{canRewrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
setError(null) // clear a previous failure so a retry isn't read as another
|
||||
del.mutate(entry.id, {
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
|
||||
})
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { TextAreaField, TextField } from '@/components/ui/Field'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
formatObservedAt,
|
||||
today,
|
||||
useCreateJournalEntry,
|
||||
useDeleteJournalEntry,
|
||||
useJournal,
|
||||
useUpdateJournalEntry,
|
||||
type JournalEntry,
|
||||
type JournalFilter,
|
||||
} from '@/lib/journal'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { objectDisplayName } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/** What a new note attaches to: the selection, else the focused bed, else the
|
||||
* garden — and how to say it. */
|
||||
export interface JournalAttach {
|
||||
objectId?: number
|
||||
plantingId?: number
|
||||
label: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The grow journal: entries newest first as cards (what it's about, when, the
|
||||
* note), a one-line composer that attaches to whatever's selected — Enter
|
||||
* submits — and, when the rail was opened from a bed's "N notes", a filter chip
|
||||
* narrowing the list to that thing. Notes get written standing in the garden
|
||||
* holding a phone, so the composer is never more than a tap away.
|
||||
*/
|
||||
export function JournalTab({
|
||||
gardenId,
|
||||
canEdit,
|
||||
currentUserId,
|
||||
isOwner,
|
||||
objects,
|
||||
plantings,
|
||||
attach,
|
||||
composerFirst = false,
|
||||
large = false,
|
||||
}: {
|
||||
gardenId: number
|
||||
canEdit: boolean
|
||||
currentUserId?: number
|
||||
isOwner: boolean
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
attach: JournalAttach
|
||||
/** Phone: the input above the entries (the thumb is at the bottom anyway). */
|
||||
composerFirst?: boolean
|
||||
large?: boolean
|
||||
}) {
|
||||
const scope = useEditorStore((s) => s.journalScope)
|
||||
const setScope = useEditorStore((s) => s.setJournalScope)
|
||||
const filter: JournalFilter = scope?.type === 'object' ? { objectId: scope.id } : scope?.type === 'plop' ? { plantingId: scope.id } : {}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
const scopeName =
|
||||
scope?.type === 'object'
|
||||
? objectDisplayName(objects.find((o) => o.id === scope.id) ?? { name: '', kind: 'object' })
|
||||
: scope?.type === 'plop'
|
||||
? 'one planting'
|
||||
: null
|
||||
|
||||
const composer = canEdit && (
|
||||
<Composer gardenId={gardenId} attach={attach} large={large} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
|
||||
{scopeName && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-accent-2-200 py-1 pl-3.5 pr-1 text-xs font-semibold text-accent-2-800">
|
||||
Notes about {scopeName}
|
||||
<IconButton label="Show the whole journal" icon="x" iconSize={12} variant="plain" size={26} className="ml-auto" onClick={() => setScope(null)} />
|
||||
</div>
|
||||
)}
|
||||
{composerFirst && composer}
|
||||
{journal.isPending && <p className="text-[13px] text-ink-mute">Loading…</p>}
|
||||
{journal.isError && entries.length === 0 && <Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>}
|
||||
{journal.isSuccess && entries.length === 0 && (
|
||||
<p className="text-[13px] leading-relaxed text-ink-mute">
|
||||
{scopeName
|
||||
? `Nothing written about ${scopeName} yet.`
|
||||
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||
</p>
|
||||
)}
|
||||
{entries.map((e) => (
|
||||
<Entry
|
||||
key={e.id}
|
||||
entry={e}
|
||||
gardenId={gardenId}
|
||||
objects={objects}
|
||||
plantings={plantings}
|
||||
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
|
||||
canRewrite={canEdit && e.authorId === currentUserId}
|
||||
large={large}
|
||||
/>
|
||||
))}
|
||||
{journal.hasNextPage && (
|
||||
<Button variant="ghost" className="self-start text-[13px]" disabled={journal.isFetchingNextPage} onClick={() => void journal.fetchNextPage()}>
|
||||
{journal.isFetchingNextPage ? 'Loading…' : 'Older notes'}
|
||||
</Button>
|
||||
)}
|
||||
{!composerFirst && <div className="mt-auto pt-1.5">{composer}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Composer({ gardenId, attach, large }: { gardenId: number; attach: JournalAttach; large: boolean }) {
|
||||
const create = useCreateJournalEntry(gardenId)
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const submit = () => {
|
||||
const text = body.trim()
|
||||
if (!text || create.isPending) return
|
||||
setError(null)
|
||||
create.mutate(
|
||||
{ body: text, observedAt: today(), objectId: attach.objectId, plantingId: attach.plantingId },
|
||||
{ onSuccess: () => setBody(''), onError: (err) => setError(errorMessage(err, "Couldn't save that note.")) },
|
||||
)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className={cn('input', large && 'input-lg')}
|
||||
placeholder={attach.label ? `Note something about ${attach.label}…` : 'Note something…'}
|
||||
aria-label="New journal note"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
<IconButton label="Log it" icon="plus" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={create.isPending || !body.trim()} onClick={submit} />
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Entry({
|
||||
entry,
|
||||
gardenId,
|
||||
objects,
|
||||
plantings,
|
||||
canDelete,
|
||||
canRewrite,
|
||||
large,
|
||||
}: {
|
||||
entry: JournalEntry
|
||||
gardenId: number
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
canDelete: boolean
|
||||
canRewrite: boolean
|
||||
large: boolean
|
||||
}) {
|
||||
const update = useUpdateJournalEntry(gardenId)
|
||||
const del = useDeleteJournalEntry(gardenId)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(entry.body)
|
||||
const [observedAt, setObservedAt] = useState(entry.observedAt)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// A plop-level note names its bed; an object-level note names the object.
|
||||
const objectId = entry.objectId ?? (entry.plantingId != null ? plantings.find((p) => p.id === entry.plantingId)?.objectId : undefined)
|
||||
const about = objects.find((o) => o.id === objectId)
|
||||
const aboutLabel = about ? objectDisplayName(about) + (entry.plantingId != null ? ' · planting' : '') : entry.plantingId != null ? 'a planting' : 'Garden'
|
||||
|
||||
const save = () => {
|
||||
const text = draft.trim()
|
||||
if (!text) return setError("An entry needs some text — delete it instead if that's what you meant.")
|
||||
setError(null)
|
||||
update.mutate(
|
||||
{ id: entry.id, version: entry.version, body: text, observedAt },
|
||||
{ onSuccess: () => setEditing(false), onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")) },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-divider bg-bg px-3.5 py-3">
|
||||
<div className="mb-[5px] flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-bold text-accent-2-700">{aboutLabel}</span>
|
||||
<span className="ml-auto text-[11.5px] text-ink-mute" title={`by ${entry.authorName}`}>
|
||||
{formatObservedAt(entry.observedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextAreaField label="Note" name={`entry-${entry.id}`} rows={3} className={cn(large && 'text-base')} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
||||
<TextField label="Observed on" name={`observed-${entry.id}`} type="date" value={observedAt} onChange={(e) => setObservedAt(e.target.value)} />
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2.5 text-[13px]"
|
||||
onClick={() => {
|
||||
setDraft(entry.body)
|
||||
setObservedAt(entry.observedAt)
|
||||
setEditing(false)
|
||||
setError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" className="px-3.5 text-[13px]" disabled={update.isPending} onClick={save}>
|
||||
{update.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('whitespace-pre-wrap leading-relaxed', large ? 'text-[13.5px]' : 'text-[13px]')}>{entry.body}</div>
|
||||
)}
|
||||
{(canRewrite || canDelete) && !editing && (
|
||||
<div className="-mb-1 mt-1 flex justify-end gap-0.5">
|
||||
{canRewrite && (
|
||||
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost px-2 py-0.5 text-[11.5px] text-accent-700"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
del.mutate(entry.id, { onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")) })
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-1 text-xs font-semibold text-accent-700">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { kindStyle, type KindDef } from './kinds'
|
||||
|
||||
/** A kind's mini shape swatch: true proportions, its own fill and stroke — the
|
||||
* toolkit's "icon" is the thing itself, not a glyph. */
|
||||
export function KindSwatch({ def, compact = false }: { def: KindDef; compact?: boolean }) {
|
||||
const st = kindStyle(def.kind)
|
||||
const f = 24 / Math.max(def.widthCm, def.heightCm)
|
||||
const w = def.widthCm * f
|
||||
const h = def.heightCm * f
|
||||
return (
|
||||
<svg
|
||||
width={compact ? 30 : 38}
|
||||
height={compact ? 24 : 30}
|
||||
viewBox={compact ? '-20 -13 40 26' : '-20 -15 40 30'}
|
||||
className="flex-none"
|
||||
aria-hidden
|
||||
>
|
||||
{def.shape === 'rect' ? (
|
||||
<rect x={-w / 2} y={-h / 2} width={w} height={h} rx={3} fill={st.fill} stroke={st.stroke} strokeWidth={1.5} strokeDasharray={st.dash ? '3 3' : undefined} />
|
||||
) : (
|
||||
<circle r={w / 2} fill={st.fill} stroke={st.stroke} strokeWidth={1.5} strokeDasharray={st.dash ? '3 3' : undefined} />
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -8,33 +8,20 @@ const remarkPlugins = [remarkGfm]
|
||||
const CODE_BLOCK = /language-/
|
||||
|
||||
// The assistant's replies are never trusted markup: their content can be steered
|
||||
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
|
||||
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
|
||||
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
|
||||
// (``); the assistant has no reason to emit images.
|
||||
// by anything the agent read. So we render Markdown but NOT raw HTML (no
|
||||
// rehype-raw), and forbid <img>, whose auto-loading `src` is a prompt-injection
|
||||
// exfiltration beacon; the assistant has no reason to emit images.
|
||||
const disallowedElements = ['img']
|
||||
|
||||
// Tailwind's reset strips default list/table styling, so every element the
|
||||
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
|
||||
// content (tables, code) scrolls in its own box so the bubble never blows out.
|
||||
const bigHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
|
||||
)
|
||||
const bigHeading = ({ children }: { children?: ReactNode }) => <h4 className="mb-1 mt-2 text-[15px] first:mt-0">{children}</h4>
|
||||
const smallHeading = ({ children }: { children?: ReactNode }) => (
|
||||
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
|
||||
{children}
|
||||
</h5>
|
||||
<h6 className="mb-1 mt-1.5 text-[11px] text-ink-mute first:mt-0">{children}</h6>
|
||||
)
|
||||
|
||||
const components: Components = {
|
||||
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-strong underline underline-offset-2"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
@@ -45,7 +32,7 @@ const components: Components = {
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
h1: bigHeading,
|
||||
h2: bigHeading,
|
||||
@@ -53,40 +40,27 @@ const components: Components = {
|
||||
h4: smallHeading,
|
||||
h5: smallHeading,
|
||||
h6: smallHeading,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
blockquote: ({ children }) => <blockquote className="my-1.5 border-l-2 border-accent-2-400 pl-2 text-ink-soft">{children}</blockquote>,
|
||||
hr: () => <hr className="my-2 border-divider" />,
|
||||
code: ({ className, children }) => {
|
||||
// A fenced block is wrapped by <pre> (styled below) and carries either a
|
||||
// language- class or a trailing newline; inline code is a single-line bare
|
||||
// <code> and gets the pill treatment. (The newline check catches fences with
|
||||
// no info-string, which have no language- class.)
|
||||
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
|
||||
if (isBlock) return <code className={className}>{children}</code>
|
||||
return (
|
||||
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
)
|
||||
return <code className="rounded-sm bg-neutral-200 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
pre: ({ children }) => <pre className="my-1.5 overflow-x-auto rounded-md bg-neutral-200 p-2 font-mono text-xs">{children}</pre>,
|
||||
table: ({ children }) => (
|
||||
<div className="my-1.5 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
|
||||
// style.textAlign, and dropping it would silently discard it.
|
||||
// Pass `style` through: GFM column alignment arrives as style.textAlign.
|
||||
th: ({ children, style }) => (
|
||||
<th style={style} className="border border-border px-2 py-1 font-semibold">
|
||||
<th style={style} className="border border-divider px-2 py-1 font-bold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, style }) => (
|
||||
<td style={style} className="border border-border px-2 py-1 align-top">
|
||||
<td style={style} className="border border-divider px-2 py-1 align-top">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
@@ -97,11 +71,7 @@ const components: Components = {
|
||||
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
disallowedElements={disallowedElements}
|
||||
components={components}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} disallowedElements={disallowedElements} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { objectTransform } from './shared'
|
||||
import { kindDef, objectDisplayName } from './kinds'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const DEFAULT_FILL = '#8a8a8a'
|
||||
|
||||
// Default fills by kind (overridable per object via object.color). Muted, earthy
|
||||
// tones so plops (added in #15) read clearly on top.
|
||||
const kindColors: Record<string, string> = {
|
||||
bed: '#8a6d4b',
|
||||
grow_bag: '#9c7a52',
|
||||
container: '#6b7f8a',
|
||||
in_ground: '#7a6a4a',
|
||||
tree: '#4f7a4f',
|
||||
path: '#b8b0a0',
|
||||
structure: DEFAULT_FILL,
|
||||
}
|
||||
|
||||
// Label font size = this fraction of the object's smaller side, clamped to a
|
||||
// readable cm range. Corner radius = this fraction of the smaller half-side.
|
||||
const LABEL_FONT_FACTOR = 0.28
|
||||
const LABEL_FONT_MIN_CM = 8
|
||||
const LABEL_FONT_MAX_CM = 40
|
||||
const CORNER_RADIUS_FACTOR = 0.06
|
||||
|
||||
function fillFor(o: EditorObject): string {
|
||||
return o.color ?? kindColors[o.kind] ?? DEFAULT_FILL
|
||||
}
|
||||
|
||||
/**
|
||||
* One garden object in world (cm) space: a centered rect or ellipse (a circle
|
||||
* when width == height), rotated about its center, with the object's name. The
|
||||
* parent world <g> applies the viewport scale, so geometry is authored in cm and
|
||||
* strokes use vector-effect=non-scaling-stroke to stay a constant pixel width at
|
||||
* any zoom. memo'd so a pan/zoom (which only changes the world <g> transform)
|
||||
* doesn't re-render every object. Full move/resize/rotate come in #11.
|
||||
*/
|
||||
export const ObjectShape = memo(function ObjectShape({
|
||||
object,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
object: EditorObject
|
||||
selected: boolean
|
||||
onSelect: (id: number) => void
|
||||
}) {
|
||||
const fill = fillFor(object)
|
||||
const halfW = Math.max(0, object.widthCm / 2)
|
||||
const halfH = Math.max(0, object.heightCm / 2)
|
||||
const fontCm = Math.max(
|
||||
LABEL_FONT_MIN_CM,
|
||||
Math.min(LABEL_FONT_MAX_CM, Math.min(object.widthCm, object.heightCm) * LABEL_FONT_FACTOR),
|
||||
)
|
||||
|
||||
function handleDown(e: PointerEvent) {
|
||||
e.stopPropagation() // don't let the canvas treat this as an empty-space pan/deselect
|
||||
onSelect(object.id)
|
||||
}
|
||||
|
||||
// Keyboard path into selection (#84): the arrow-key nudge handler already
|
||||
// exists but only ever acted on a pointer selection, so it was unreachable
|
||||
// without a mouse. Enter/Space on a focused object selects it, which is the
|
||||
// step that was missing.
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelect(object.id)
|
||||
}
|
||||
}
|
||||
|
||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||
const strokeWidth = selected ? 2 : 1
|
||||
|
||||
// A concise accessible name: the object's label plus its kind's canonical
|
||||
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||
// dimensions aren't included; they need the garden's unit context this
|
||||
// component doesn't hold, so they're a follow-up.
|
||||
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||
|
||||
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
|
||||
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
|
||||
// rather than React state because onFocus on an SVG <g> is unreliable and a
|
||||
// presentation attribute is overridden by any CSS rule.
|
||||
return (
|
||||
<g
|
||||
className="object-shape"
|
||||
transform={objectTransform(object)}
|
||||
onPointerDown={handleDown}
|
||||
onKeyDown={handleKey}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||
// item among the objects. Omitted, not "false", when unselected.
|
||||
aria-current={selected || undefined}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse
|
||||
cx={0}
|
||||
cy={0}
|
||||
rx={halfW}
|
||||
ry={halfH}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={-halfW}
|
||||
y={-halfH}
|
||||
width={halfW * 2}
|
||||
height={halfH * 2}
|
||||
rx={Math.min(halfW, halfH) * CORNER_RADIUS_FACTOR}
|
||||
fill={fill}
|
||||
fillOpacity={0.85}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
|
||||
{object.name && (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize={fontCm}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="#ffffff"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{object.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { OBJECT_KINDS, kindDef } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
/**
|
||||
* The object-kind palette. Tap a kind to arm it, then tap the canvas to place
|
||||
* (works on desktop and touch). Tapping the armed kind again disarms it.
|
||||
*/
|
||||
export function Palette() {
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 md:flex-col">
|
||||
{OBJECT_KINDS.map((k) => {
|
||||
const active = armedKind === k.kind
|
||||
return (
|
||||
<button
|
||||
key={k.kind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
select(null)
|
||||
setArmedKind(active ? null : k.kind)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong'
|
||||
: 'border-border bg-surface text-fg hover:bg-border/50',
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span aria-hidden className="text-base leading-none">
|
||||
{k.icon}
|
||||
</span>
|
||||
<span>{k.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{armedKind && (
|
||||
<p className="text-xs text-muted">Tap the canvas to place a {kindDef(armedKind)?.label ?? 'object'}.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fieldControlClass } from '@/components/ui/field'
|
||||
import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import {
|
||||
attributableLots,
|
||||
formatQuantity,
|
||||
lotsByPlant,
|
||||
lotState,
|
||||
summarizeLots,
|
||||
useSeedLots,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
import { LotStateChip } from '@/components/plants/SeedLotList'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
|
||||
const RECENT_KEY = 'pansy:recent-plants'
|
||||
const RECENT_MAX = 8
|
||||
|
||||
/** Recently-picked plant ids, most-recent first, from localStorage. */
|
||||
function loadRecent(): number[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
|
||||
return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepend id (dedup, capped) and persist; returns the new list. */
|
||||
function recordRecent(id: number): number[] {
|
||||
const next = [id, ...loadRecent().filter((n) => n !== id)].slice(0, RECENT_MAX)
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// Recents are a nicety; ignore quota/availability failures.
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable plant chooser: a search-first list with a recently-used shortcut and
|
||||
* category filter, rendered as a bottom sheet on mobile / centered panel on
|
||||
* desktop. `onSelect` fires with the chosen plant (and records it as recent).
|
||||
* The plops editor (#15) opens this when placing plants; /plants demos it.
|
||||
*/
|
||||
export function PlantPicker({
|
||||
onSelect,
|
||||
onClose,
|
||||
unit = 'metric',
|
||||
}: {
|
||||
// The chosen plant, and the lot it should be attributed to when that isn't
|
||||
// ambiguous. Undefined lot means "don't attribute" — which is the honest
|
||||
// answer when there are no lots, and the deliberate one when the user skips.
|
||||
onSelect: (plant: Plant, lot?: SeedLot) => void
|
||||
onClose: () => void
|
||||
unit?: UnitPref
|
||||
}) {
|
||||
const plants = usePlants()
|
||||
const seedLots = useSeedLots()
|
||||
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
// Set when a plant with SEVERAL lots is picked: which packet did this come out
|
||||
// of? One lot auto-attributes and zero lots stays silent, because forcing that
|
||||
// question on every placement is how a nicety becomes an obstacle.
|
||||
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus()
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCloseRef.current()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const all = useMemo(() => plants.data ?? [], [plants.data])
|
||||
|
||||
const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category])
|
||||
|
||||
// Recents show only when not actively searching, and only rows still present.
|
||||
const recentPlants = useMemo(() => {
|
||||
if (query.trim() !== '') return []
|
||||
const byId = new Map(all.map((p) => [p.id, p]))
|
||||
return recent.map((id) => byId.get(id)).filter((p): p is Plant => !!p)
|
||||
}, [all, recent, query])
|
||||
|
||||
function choose(p: Plant) {
|
||||
const lots = attributableLots(lotsFor.get(p.id) ?? [])
|
||||
if (lots.length > 1) {
|
||||
setChoosingLotFor(p)
|
||||
return
|
||||
}
|
||||
setRecent(recordRecent(p.id))
|
||||
// A single lot attributes even when its count says empty. The count records
|
||||
// what was written down, not a fact about the packet — dropping attribution
|
||||
// there would make a wrong number harder to correct rather than easier.
|
||||
onSelect(p, lots[0])
|
||||
}
|
||||
|
||||
function chooseLot(p: Plant, lot?: SeedLot) {
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p, lot)
|
||||
}
|
||||
|
||||
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
||||
// both the Recent and All sections doesn't collide on a duplicate React key.
|
||||
const row = (p: Plant, keyPrefix: string) => (
|
||||
<button
|
||||
key={`${keyPrefix}-${p.id}`}
|
||||
type="button"
|
||||
onClick={() => choose(p)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
<PlantIcon color={p.color} icon={p.icon} className="h-9 w-9 rounded-md text-xl" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-fg">{p.name}</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
||||
{remainingLabel(lotsFor.get(p.id) ?? [])}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
// What's left, shown where you're deciding what to plant. Silent when there
|
||||
// are no lots — most plants won't have any, and "0 left" on all of them would
|
||||
// be noise that trains you to ignore the number. summarizeLots owns the
|
||||
// unit-agreement rule; a second copy of it here would drift.
|
||||
function remainingLabel(lots: SeedLot[]): string {
|
||||
if (lots.length === 0) return ''
|
||||
const { remaining, unit } = summarizeLots(lots)
|
||||
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
||||
onMouseDown={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Choose a plant"
|
||||
className="flex max-h-[85vh] w-full flex-col rounded-t-2xl border border-border bg-surface shadow-xl sm:max-w-lg sm:rounded-2xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border p-3">
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search plants…"
|
||||
aria-label="Search plants"
|
||||
className={cn(fieldControlClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md px-2 py-2 text-muted outline-none transition-colors hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
||||
</div>
|
||||
|
||||
{choosingLotFor && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Which lot of {choosingLotFor.name}?
|
||||
</p>
|
||||
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, lot)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg">
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
|
||||
</span>
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, undefined)}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
Don't attribute to a lot
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
|
||||
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
|
||||
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
|
||||
|
||||
{recentPlants.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent</p>
|
||||
{recentPlants.map((p) => row(p, 'recent'))}
|
||||
<p className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted">All plants</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filtered.map((p) => row(p, 'all'))}
|
||||
|
||||
{plants.isSuccess && filtered.length === 0 && (
|
||||
<p className="p-4 text-sm text-muted">No plants match your search.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { useRemovePlanting, useUpdatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
import { MIN_RADIUS_CM } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
/**
|
||||
* Property panel for the selected plop. Radius/label/date/count commit a PATCH on
|
||||
* blur (carrying the version); the count field shows the live derived value as a
|
||||
* placeholder and takes an override when typed. "Remove" soft-removes (keeps the
|
||||
* row with removed_at); "Change plant" opens the picker via onChangePlant. While
|
||||
* the plop is dragged/resized on the canvas, it reads livePlanting for live
|
||||
* feedback (subscribed here, not at the page level, so a drag only re-renders
|
||||
* this panel).
|
||||
*/
|
||||
export function PlopInspector({
|
||||
plop,
|
||||
plant,
|
||||
gardenId,
|
||||
unit,
|
||||
onChangePlant,
|
||||
onClose,
|
||||
onAddNote,
|
||||
readOnly = false,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant?: Plant
|
||||
gardenId: number
|
||||
unit: UnitPref
|
||||
onChangePlant: () => void
|
||||
onClose: () => void
|
||||
/** Scope the journal to this plop and open it — the plop parallel of the bed
|
||||
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
|
||||
* notes, like the bed inspector does); the journal's composer is separately
|
||||
* gated on edit rights, so a viewer just sees the entries. */
|
||||
onAddNote?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
const remove = useRemovePlanting(gardenId)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// The plop with any in-flight drag/resize geometry applied.
|
||||
const p = livePlanting && livePlanting.id === plop.id ? livePlanting : plop
|
||||
|
||||
const [radius, setRadius] = useState(String(spacingFromCm(p.radiusCm, unit)))
|
||||
const [count, setCount] = useState(p.count != null ? String(p.count) : '')
|
||||
const [label, setLabel] = useState(p.label ?? '')
|
||||
const [planted, setPlanted] = useState(p.plantedAt ?? '')
|
||||
|
||||
// Re-sync when the plop changes underneath us (a drag/resize or a server row),
|
||||
// unless the user is editing a field here.
|
||||
useEffect(() => {
|
||||
if (rootRef.current?.contains(document.activeElement)) return
|
||||
setRadius(String(spacingFromCm(p.radiusCm, unit)))
|
||||
setCount(p.count != null ? String(p.count) : '')
|
||||
setLabel(p.label ?? '')
|
||||
setPlanted(p.plantedAt ?? '')
|
||||
}, [p.version, p.radiusCm, p.count, p.label, p.plantedAt, unit])
|
||||
|
||||
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) => {
|
||||
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
|
||||
update.mutate({ id: plop.id, version: plop.version, ...fields })
|
||||
}
|
||||
|
||||
const u = spacingUnitLabel(unit)
|
||||
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
||||
|
||||
function commitRadius() {
|
||||
const v = Number(radius)
|
||||
if (radius.trim() === '' || !Number.isFinite(v)) {
|
||||
setRadius(String(spacingFromCm(p.radiusCm, unit))) // reset stale/invalid text
|
||||
return
|
||||
}
|
||||
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
|
||||
if (cm !== p.radiusCm) patch({ radiusCm: cm })
|
||||
}
|
||||
|
||||
function commitCount() {
|
||||
if (count.trim() === '') {
|
||||
if (p.count != null) patch({ count: null }) // restore derived
|
||||
return
|
||||
}
|
||||
const n = Number(count)
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
setCount(p.count != null ? String(p.count) : '') // reject invalid, restore
|
||||
return
|
||||
}
|
||||
if (n !== p.count) patch({ count: n })
|
||||
}
|
||||
|
||||
function commitPlanted() {
|
||||
const next = planted === '' ? null : planted
|
||||
if (next !== (p.plantedAt ?? null)) patch({ plantedAt: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg">Plant</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded px-1.5 text-sm text-muted hover:text-fg"
|
||||
aria-label="Close inspector"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{readOnly && (
|
||||
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">View only — you can't edit this garden.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border p-2">
|
||||
{plant ? (
|
||||
<PlantIcon color={plant.color} icon={plant.icon} className="h-9 w-9 rounded-md text-xl" />
|
||||
) : (
|
||||
<span className="grid h-9 w-9 place-items-center rounded-md bg-border/50 text-muted">?</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">{plant?.name ?? 'Unknown plant'}</span>
|
||||
{!readOnly && (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onChangePlant}>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Radius (${u})`}
|
||||
name="radius"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(e.target.value)}
|
||||
onBlur={commitRadius}
|
||||
/>
|
||||
<TextField
|
||||
label="Count"
|
||||
name="count"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
placeholder={`${derived} (auto)`}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
onBlur={commitCount}
|
||||
hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Label (optional)"
|
||||
name="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Planted"
|
||||
name="planted"
|
||||
type="date"
|
||||
value={planted}
|
||||
onChange={(e) => setPlanted(e.target.value)}
|
||||
onBlur={commitPlanted}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{onAddNote && (
|
||||
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
|
||||
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-600 dark:text-red-400"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
remove.mutate({ id: plop.id, version: plop.version })
|
||||
}}
|
||||
>
|
||||
Remove plant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { PlopMarker, SEMANTIC_FAR } from './PlopMarker'
|
||||
import { DIMMED_OPACITY, objectTransform } from './shared'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/** The most common plant color among an object's plops (for the far-zoom tint). */
|
||||
function dominantColor(plops: EditorPlanting[], plantsById: Map<number, Plant>): string | null {
|
||||
const freq = new Map<string, number>()
|
||||
for (const p of plops) {
|
||||
const c = plantsById.get(p.plantId)?.color
|
||||
if (c) freq.set(c, (freq.get(c) ?? 0) + 1)
|
||||
}
|
||||
let best: string | null = null
|
||||
let bestN = 0
|
||||
for (const [c, n] of freq) {
|
||||
if (n > bestN) {
|
||||
best = c
|
||||
bestN = n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every active plop, grouped under its parent object's translate+rotate
|
||||
* transform so plops track the bed when it's moved/rotated. Far zoom adds a
|
||||
* dominant-plant tint over each planted object so beds read at a glance. Focus
|
||||
* mode dims plops on non-focused objects.
|
||||
*/
|
||||
export const PlopLayer = memo(function PlopLayer({
|
||||
objects,
|
||||
plantings,
|
||||
plantsById,
|
||||
scale,
|
||||
focusedObjectId,
|
||||
selectedPlantingId,
|
||||
onSelectPlop,
|
||||
}: {
|
||||
objects: EditorObject[]
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
scale: number
|
||||
focusedObjectId: number | null
|
||||
selectedPlantingId: number | null
|
||||
onSelectPlop: (id: number) => void
|
||||
}) {
|
||||
// Group plops by object once per plops change (not on every pan/zoom frame).
|
||||
const byObject = useMemo(() => {
|
||||
const m = new Map<number, EditorPlanting[]>()
|
||||
for (const p of plantings) {
|
||||
const arr = m.get(p.objectId)
|
||||
if (arr) arr.push(p)
|
||||
else m.set(p.objectId, [p])
|
||||
}
|
||||
return m
|
||||
}, [plantings])
|
||||
const far = scale < SEMANTIC_FAR
|
||||
|
||||
return (
|
||||
<>
|
||||
{objects.map((o) => {
|
||||
const plops = byObject.get(o.id)
|
||||
if (!plops || plops.length === 0) return null
|
||||
const dimmed = focusedObjectId != null && focusedObjectId !== o.id
|
||||
const halfW = o.widthCm / 2
|
||||
const halfH = o.heightCm / 2
|
||||
const tint = far ? dominantColor(plops, plantsById) : null
|
||||
return (
|
||||
<g key={o.id} transform={objectTransform(o)} opacity={dimmed ? DIMMED_OPACITY : 1}>
|
||||
{tint &&
|
||||
(o.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill={tint} fillOpacity={0.5} pointerEvents="none" />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={halfW * 2} height={halfH * 2} fill={tint} fillOpacity={0.5} pointerEvents="none" />
|
||||
))}
|
||||
{plops.map((p) => (
|
||||
<PlopMarker
|
||||
key={p.id}
|
||||
plop={p}
|
||||
plant={plantsById.get(p.plantId)}
|
||||
scale={scale}
|
||||
selected={p.id === selectedPlantingId}
|
||||
onSelect={onSelectPlop}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { memo, type PointerEvent } from 'react'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
|
||||
import { SELECT_COLOR } from './shared'
|
||||
|
||||
// Semantic-zoom thresholds in px/cm (DESIGN § Editor / rendering), tuned by feel:
|
||||
// < FAR — flat color patch, no text ("what's planted where" at a glance)
|
||||
// FAR..NEAR — colored circle + plant emoji
|
||||
// ≥ NEAR — circle + emoji + plant name + count
|
||||
export const SEMANTIC_FAR = 0.75
|
||||
export const SEMANTIC_NEAR = 3
|
||||
|
||||
const DEFAULT_COLOR = '#6aa84f'
|
||||
|
||||
/**
|
||||
* One plop, rendered in its parent object's local frame (the caller wraps it in
|
||||
* the object's translate+rotate group, so the plop tracks the bed). A circle in
|
||||
* the plant's color; emoji and name/count fade in with zoom. The count is
|
||||
* computed live from the current radius so it updates while resizing. memo'd so a
|
||||
* pan/zoom that only changes the world transform doesn't re-render every plop.
|
||||
*/
|
||||
export const PlopMarker = memo(function PlopMarker({
|
||||
plop,
|
||||
plant,
|
||||
scale,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
plant?: Plant
|
||||
scale: number
|
||||
selected: boolean
|
||||
onSelect: (id: number) => void
|
||||
}) {
|
||||
const color = plant?.color ?? DEFAULT_COLOR
|
||||
const r = Math.max(0, plop.radiusCm)
|
||||
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
|
||||
const showText = scale >= SEMANTIC_NEAR && !!plant
|
||||
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
|
||||
// Keep the tap target at least ~44px across even when the plop draws tiny at
|
||||
// low zoom (fill=transparent still receives pointer events, unlike fill=none).
|
||||
const hitR = Math.max(r, 22 / scale)
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
e.stopPropagation()
|
||||
onSelect(plop.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
|
||||
<circle cx={0} cy={0} r={hitR} fill="transparent" />
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={r}
|
||||
fill={color}
|
||||
fillOpacity={0.82}
|
||||
stroke={selected ? SELECT_COLOR : '#00000033'}
|
||||
strokeWidth={selected ? 2.5 : 1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{showIcon && (
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize={r * 1.2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{plant!.icon}
|
||||
</text>
|
||||
)}
|
||||
{showText && (
|
||||
<text
|
||||
x={0}
|
||||
y={r + r * 0.3}
|
||||
fontSize={Math.max(r * 0.5, 6)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="hanging"
|
||||
fill="#1f2937"
|
||||
stroke="#ffffff"
|
||||
strokeWidth={0.5}
|
||||
paintOrder="stroke"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{plant!.name} · {count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||
import { screenToWorld, snapLocalToBedGrid, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import { useUpdatePlanting } from '@/lib/objects'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const MAX_RADIUS_CM = 10_000
|
||||
|
||||
/**
|
||||
* Move/resize handles for the selected plop, rendered inside its parent object's
|
||||
* translate+rotate group so the plop stays in the object's local frame. Drag the
|
||||
* body to move (clamped to the object's bounds); drag the edge handle to resize
|
||||
* the radius. Each gesture updates livePlanting for instant feedback and fires
|
||||
* one PATCH on release — the same contract as SelectionOverlay (#11).
|
||||
*/
|
||||
export function PlopOverlay({
|
||||
plop,
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
snap,
|
||||
gridCm,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
svgRef: RefObject<SVGSVGElement | null>
|
||||
// The parent bed's grid: when snap is true, moving the plop snaps it to the
|
||||
// bed grid (radius stays free either way).
|
||||
snap: boolean
|
||||
gridCm: number
|
||||
}) {
|
||||
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
||||
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
||||
const scale = useEditorStore((s) => s.viewport.scale)
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleCm = HANDLE_PX / scale
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
const center: Point = { x: object.xCm, y: object.yCm }
|
||||
const rot = object.rotationDeg
|
||||
|
||||
// Pointer (client) → the object's local frame, snapshotting the svg rect once
|
||||
// per gesture (the object doesn't move during a plop drag).
|
||||
const makePointerLocal = () => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return (e: { clientX: number; clientY: number }): Point => {
|
||||
const vp = useEditorStore.getState().viewport
|
||||
const canvas = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
||||
return worldToLocal(screenToWorld(canvas, vp), center, rot)
|
||||
}
|
||||
}
|
||||
|
||||
const begin = (
|
||||
e: ReactPointerEvent,
|
||||
onMove: (e: PointerEvent) => EditorPlanting,
|
||||
fields: (final: EditorPlanting) => Parameters<typeof update.mutate>[0],
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setObjectDragging(true)
|
||||
const move = (ev: PointerEvent) => setLivePlanting(onMove(ev))
|
||||
const detach = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', finish)
|
||||
window.removeEventListener('pointercancel', finish)
|
||||
}
|
||||
const finish = () => {
|
||||
detach()
|
||||
cleanupRef.current = null
|
||||
const final = useEditorStore.getState().livePlanting
|
||||
setObjectDragging(false)
|
||||
setLivePlanting(null)
|
||||
if (final) update.mutate(fields(final))
|
||||
}
|
||||
cleanupRef.current = () => {
|
||||
detach()
|
||||
setObjectDragging(false)
|
||||
setLivePlanting(null)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', finish)
|
||||
window.addEventListener('pointercancel', finish)
|
||||
}
|
||||
|
||||
const base = { ...plop }
|
||||
|
||||
const startMove = (e: ReactPointerEvent) => {
|
||||
const ptr = makePointerLocal()
|
||||
const start = ptr(e.nativeEvent)
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const p = ptr(ev)
|
||||
const moved: Point = { x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) }
|
||||
// snapLocalToBedGrid clamps either way; step 0 (snapping off) makes it a
|
||||
// pure clamp, so both paths go through one helper.
|
||||
const next = snapLocalToBedGrid(moved, snap ? gridCm : 0, halfW, halfH)
|
||||
return { ...base, xCm: next.x, yCm: next.y }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startResize = (e: ReactPointerEvent) => {
|
||||
const ptr = makePointerLocal()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const p = ptr(ev)
|
||||
const r = Math.max(MIN_RADIUS_CM, Math.min(MAX_RADIUS_CM, Math.hypot(p.x - base.xCm, p.y - base.yCm)))
|
||||
return { ...base, radiusCm: r }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, radiusCm: f.radiusCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const r = plop.radiusCm
|
||||
return (
|
||||
<g transform={objectTransform(object)}>
|
||||
{/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */}
|
||||
<circle
|
||||
cx={plop.xCm}
|
||||
cy={plop.yCm}
|
||||
r={Math.max(r, handleCm)}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
style={{ cursor: 'move' }}
|
||||
onPointerDown={startMove}
|
||||
/>
|
||||
{/* Selection ring. */}
|
||||
<circle
|
||||
cx={plop.xCm}
|
||||
cy={plop.yCm}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{/* Radius handle at the local +x edge. */}
|
||||
<circle
|
||||
cx={plop.xCm + r}
|
||||
cy={plop.yCm}
|
||||
r={handleCm * 0.6}
|
||||
fill="#ffffff"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ cursor: 'ew-resize' }}
|
||||
onPointerDown={startResize}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { PlantChip } from '@/components/plants/PlantChip'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
|
||||
* so re-placing "more of the same" is one tap instead of a trip through the
|
||||
* catalog or the manual tray. Derived from actual plantings (see
|
||||
* recentlyPlantedIds), newest first; renders nothing until something's planted.
|
||||
* Tap a chip to arm it for placement (the armed one is highlighted).
|
||||
*/
|
||||
export function RecentPlants({
|
||||
plants,
|
||||
armedPlantId,
|
||||
onArm,
|
||||
}: {
|
||||
plants: Plant[]
|
||||
armedPlantId: number | null
|
||||
onArm: (plant: Plant) => void
|
||||
}) {
|
||||
if (plants.length === 0) return null
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
|
||||
{plants.map((p) => (
|
||||
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* Which season the canvas is showing.
|
||||
*
|
||||
* "Now" is the live, editable garden — what is in the ground today. A year is a
|
||||
* read-only view of everything whose time in the ground overlapped that calendar
|
||||
* year, plops since removed included. They are genuinely different questions:
|
||||
* "what's growing" versus "what did I grow", and only the first one is editable.
|
||||
*
|
||||
* Only years the garden holds data for are offered. A free numeric field invites
|
||||
* a typo, and a typo'd year produces a confidently empty garden that reads as
|
||||
* data loss rather than a mistake.
|
||||
*/
|
||||
export function SeasonPicker({
|
||||
years,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
years: number[]
|
||||
value: number | null
|
||||
onChange: (year: number | null) => void
|
||||
}) {
|
||||
if (years.length === 0) return null
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span>Season</span>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
)}
|
||||
>
|
||||
<option value="">Now</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner that stops you thinking you're looking at now. Editing the past by
|
||||
* accident is the failure mode this whole feature introduces, so the state is
|
||||
* stated rather than implied by a dropdown you set a while ago — with the way
|
||||
* back to the live garden right next to it.
|
||||
*/
|
||||
export function SeasonBanner({ year, onExit }: { year: number; onExit: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-sm">
|
||||
<span className="font-medium text-amber-800 dark:text-amber-300">Viewing {year}</span>
|
||||
<span className="text-xs text-amber-800/80 dark:text-amber-300/80">
|
||||
read-only, including plantings since removed
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExit}
|
||||
className="ml-auto rounded px-1.5 py-0.5 text-xs font-medium text-amber-900 underline outline-none hover:no-underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-amber-200"
|
||||
>
|
||||
Back to now
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { cn } from '@/lib/cn'
|
||||
import { PlantChip } from '@/components/plants/PlantChip'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* The Seed Tray strip in the focus-mode toolbar: a row of the garden's
|
||||
* kept-at-hand plants. Tap a chip to arm that plant for tap-to-place (the armed
|
||||
* chip is highlighted); ✕ removes it from the tray; the trailing "+ Plant" chip
|
||||
* opens the full catalog picker. See useSeedTray for persistence.
|
||||
*/
|
||||
export function SeedTray({
|
||||
trayPlants,
|
||||
armedPlantId,
|
||||
onArm,
|
||||
onRemove,
|
||||
onOpenPicker,
|
||||
}: {
|
||||
trayPlants: Plant[]
|
||||
armedPlantId: number | null
|
||||
onArm: (plant: Plant) => void
|
||||
onRemove: (id: number) => void
|
||||
onOpenPicker: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{trayPlants.map((p) => {
|
||||
const active = p.id === armedPlantId
|
||||
return (
|
||||
<span key={p.id} className="inline-flex items-center">
|
||||
{/* Flat right edge so the remove button below seams into one pill. */}
|
||||
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(p.id)}
|
||||
aria-label={`Remove ${p.name} from tray`}
|
||||
className={cn(
|
||||
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
|
||||
: 'border-border bg-surface text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPicker}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-border px-2.5 py-1 text-xs font-medium text-muted outline-none transition-colors hover:border-accent hover:text-accent-strong focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
+ Plant
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||
import { localToWorld, screenToWorld, snapPoint, snapValue, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import { useUpdateObject } from '@/lib/objects'
|
||||
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object
|
||||
const MIN_OBJ_CM = 1 // smallest allowed dimension
|
||||
const ROTATE_SNAP_DEG = 15
|
||||
|
||||
const corners: [number, number][] = [
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* Handles for the selected object: a transparent body to move, four corner
|
||||
* handles to resize (opposite corner stays put, honoring rotation via the
|
||||
* object-local frame), and a knob to rotate (snaps to 15°, free with Shift).
|
||||
* Each gesture updates liveObject for instant feedback and fires exactly one
|
||||
* PATCH on release.
|
||||
*/
|
||||
export function SelectionOverlay({
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
snap,
|
||||
gridCm,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
svgRef: RefObject<SVGSVGElement | null>
|
||||
// The garden grid: when snap is true, a move snaps the object's center to it and
|
||||
// a resize snaps width/height to whole grid steps (the opposite corner stays put).
|
||||
snap: boolean
|
||||
gridCm: number
|
||||
}) {
|
||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
||||
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
||||
const scale = useEditorStore((s) => s.viewport.scale)
|
||||
const update = useUpdateObject(gardenId)
|
||||
|
||||
// Detach for an in-flight gesture. If the overlay unmounts mid-drag (the
|
||||
// object is deleted or deselected), this runs on unmount so window listeners
|
||||
// don't leak and objectDragging can't stay stuck true (which would freeze pan).
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
const handleCm = HANDLE_PX / scale
|
||||
const rotateOffsetCm = ROTATE_OFFSET_PX / scale
|
||||
|
||||
// The svg's screen rect doesn't move during a drag, so snapshot it once at
|
||||
// gesture start rather than calling getBoundingClientRect (a layout reflow) on
|
||||
// every pointermove.
|
||||
const makePointerWorld = () => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return (e: { clientX: number; clientY: number }): Point => {
|
||||
const vp = useEditorStore.getState().viewport
|
||||
const local = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
||||
return screenToWorld(local, vp)
|
||||
}
|
||||
}
|
||||
|
||||
// Common gesture scaffolding: mark dragging, track the pointer on window, and
|
||||
// on release fire one PATCH built from the final liveObject. onMove receives
|
||||
// the live pointer event so modifier keys (Shift) reflect their current state.
|
||||
const begin = (
|
||||
e: ReactPointerEvent,
|
||||
onMove: (e: PointerEvent) => EditorObject,
|
||||
fields: (final: EditorObject) => Parameters<typeof update.mutate>[0],
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setObjectDragging(true)
|
||||
const move = (ev: PointerEvent) => setLiveObject(onMove(ev))
|
||||
const detach = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', finish)
|
||||
window.removeEventListener('pointercancel', finish)
|
||||
}
|
||||
const finish = () => {
|
||||
detach()
|
||||
cleanupRef.current = null
|
||||
const final = useEditorStore.getState().liveObject
|
||||
setObjectDragging(false)
|
||||
setLiveObject(null)
|
||||
if (final) update.mutate(fields(final))
|
||||
}
|
||||
// On unmount mid-gesture: detach and reset, but don't fire a PATCH.
|
||||
cleanupRef.current = () => {
|
||||
detach()
|
||||
setObjectDragging(false)
|
||||
setLiveObject(null)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', finish)
|
||||
window.addEventListener('pointercancel', finish)
|
||||
}
|
||||
|
||||
const base = { ...object }
|
||||
const center0: Point = { x: base.xCm, y: base.yCm }
|
||||
|
||||
const startMove = (e: ReactPointerEvent) => {
|
||||
const pointerWorld = makePointerWorld()
|
||||
const start = pointerWorld(e.nativeEvent)
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
const next: Point = { x: base.xCm + (world.x - start.x), y: base.yCm + (world.y - start.y) }
|
||||
const c = snap ? snapPoint(next, gridCm) : next
|
||||
return { ...base, xCm: c.x, yCm: c.y }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startResize = (e: ReactPointerEvent, sx: number, sy: number) => {
|
||||
// The corner opposite the dragged one stays fixed (in the object's local
|
||||
// frame, which is anchored at the original center).
|
||||
const oppositeLocal: Point = { x: -sx * halfW, y: -sy * halfH }
|
||||
const pointerWorld = makePointerWorld()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
const p = worldToLocal(world, center0, base.rotationDeg)
|
||||
let newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
|
||||
let newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
|
||||
// Snap dimensions to whole grid steps (at least one cell). The opposite
|
||||
// corner is the anchor, so it stays fixed while the dragged corner lands
|
||||
// on a grid-multiple size.
|
||||
if (snap) {
|
||||
newW = Math.max(gridCm, snapValue(newW, gridCm))
|
||||
newH = Math.max(gridCm, snapValue(newH, gridCm))
|
||||
}
|
||||
const draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH }
|
||||
const newCenterLocal: Point = {
|
||||
x: (oppositeLocal.x + draggedLocal.x) / 2,
|
||||
y: (oppositeLocal.y + draggedLocal.y) / 2,
|
||||
}
|
||||
const c = localToWorld(newCenterLocal, center0, base.rotationDeg)
|
||||
return { ...base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm, widthCm: f.widthCm, heightCm: f.heightCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startRotate = (e: ReactPointerEvent) => {
|
||||
const pointerWorld = makePointerWorld()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
// +90° because the knob points up (local -y) at rotation 0.
|
||||
let deg = (Math.atan2(world.y - center0.y, world.x - center0.x) * 180) / Math.PI + 90
|
||||
// Read Shift live off the move event so toggling mid-drag switches
|
||||
// between snapped and free rotation.
|
||||
if (!ev.shiftKey) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG
|
||||
deg = ((deg % 360) + 360) % 360
|
||||
return { ...base, rotationDeg: deg }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, rotationDeg: f.rotationDeg }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={objectTransform(object)}>
|
||||
{/* Transparent body: drag to move. */}
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||
)}
|
||||
|
||||
{/* Selection outline. */}
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
)}
|
||||
|
||||
{/* Rotate knob. */}
|
||||
<line x1={0} y1={-halfH} x2={0} y2={-halfH - rotateOffsetCm} stroke={SELECT_COLOR} strokeWidth={1} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
<circle cx={0} cy={-halfH - rotateOffsetCm} r={handleCm * 0.7} fill={SELECT_COLOR} style={{ cursor: 'grab' }} onPointerDown={startRotate} />
|
||||
|
||||
{/* Resize corners. */}
|
||||
{corners.map(([sx, sy]) => (
|
||||
<rect
|
||||
key={`${sx},${sy}`}
|
||||
x={sx * halfW - handleCm / 2}
|
||||
y={sy * halfH - handleCm / 2}
|
||||
width={handleCm}
|
||||
height={handleCm}
|
||||
fill="#ffffff"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ cursor: 'nwse-resize' }}
|
||||
onPointerDown={(e) => startResize(e, sx, sy)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react'
|
||||
import { ColorDot } from '@/components/plants/Monogram'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { FillLayout } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { formatQuantity, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatSize, formatSpacing, type UnitPref } from '@/lib/units'
|
||||
import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from './arming'
|
||||
import { KindSwatch } from './KindSwatch'
|
||||
import { OBJECT_KINDS } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/**
|
||||
* The desktop editor's left card. Out of focus: the seven object kinds as pill
|
||||
* rows — click to arm (then click the plan), or drag one straight onto it. In a
|
||||
* focused bed it swaps to the plant list with a search, same arm/drag behavior,
|
||||
* plus the bed's bulk tools (fill, clear, scan a packet).
|
||||
*/
|
||||
export function Toolkit({
|
||||
unit,
|
||||
plants,
|
||||
plantings,
|
||||
lotsByPlant,
|
||||
canEdit,
|
||||
focused,
|
||||
focusedPlopCount,
|
||||
canScan,
|
||||
filling,
|
||||
onBack,
|
||||
onFill,
|
||||
onClear,
|
||||
onScan,
|
||||
}: {
|
||||
unit: UnitPref
|
||||
plants: Plant[]
|
||||
plantings: EditorPlanting[]
|
||||
lotsByPlant: Map<number, SeedLot[]>
|
||||
canEdit: boolean
|
||||
focused: EditorObject | null
|
||||
focusedPlopCount: number
|
||||
canScan: boolean
|
||||
filling: boolean
|
||||
onBack: () => void
|
||||
onFill: (layout: FillLayout) => void
|
||||
onClear: () => void
|
||||
onScan: () => void
|
||||
}) {
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const setArmedLotId = useEditorStore((s) => s.setArmedLotId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
return (
|
||||
<div className="panel flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden p-3.5">
|
||||
{!focused ? (
|
||||
<>
|
||||
<h6 className="mx-1 mb-1.5 mt-1">Toolkit</h6>
|
||||
{OBJECT_KINDS.map((k) => {
|
||||
const armed = armedKind === k.kind
|
||||
return (
|
||||
<button
|
||||
key={k.kind}
|
||||
type="button"
|
||||
draggable={canEdit}
|
||||
disabled={!canEdit}
|
||||
onDragStart={(e) => e.dataTransfer.setData('text/plain', `kind:${k.kind}`)}
|
||||
onClick={() => toggleArmedKind(k.kind)}
|
||||
aria-pressed={armed}
|
||||
title="Drag onto the plan — or click to arm, then click the plan"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-full border px-2.5 py-[7px] text-left disabled:cursor-default disabled:opacity-60',
|
||||
armed ? 'border-accent-400 bg-accent-200' : 'border-transparent hover:bg-neutral-200',
|
||||
)}
|
||||
>
|
||||
<KindSwatch def={k} />
|
||||
<span className="flex flex-col items-start gap-px">
|
||||
<span className="whitespace-nowrap text-[13px] font-bold">{k.label}</span>
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-mute">{formatSize(k.widthCm, k.heightCm, unit)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="mt-auto px-1.5 pb-0.5 pt-2 text-xs leading-relaxed text-ink-mute">
|
||||
{canEdit ? 'Drag a shape onto the plan. Double-click any bed to plant it.' : 'You can look but not change this garden.'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 mt-0.5 flex items-center gap-2">
|
||||
<IconButton label="Back to the plan" icon="chevron-left" size={30} onClick={onBack} />
|
||||
<span className="min-w-0 truncate font-heading text-base leading-[1.15]" title={focused.name}>
|
||||
{focused.name}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && focused.plantable ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Find a plant…"
|
||||
aria-label="Find a plant"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{orderedPlants(plants, plantings, query).map((p) => {
|
||||
const armed = armedPlant?.id === p.id
|
||||
const choices = armed ? lotChoices(armedPlant, lotsByPlant) : []
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Dragging arms too, so the drop knows which plant (and lot).
|
||||
if (!armed) toggleArmedPlant(p, lotsByPlant)
|
||||
e.dataTransfer.setData('text/plain', `plant:${p.id}`)
|
||||
}}
|
||||
onClick={() => toggleArmedPlant(p, lotsByPlant)}
|
||||
aria-pressed={armed}
|
||||
title="Drag into the bed — or click to arm, then click the bed"
|
||||
className={cn(
|
||||
'flex items-center gap-[9px] rounded-full border px-3 py-2 text-left',
|
||||
armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg hover:border-neutral-400',
|
||||
)}
|
||||
>
|
||||
<ColorDot color={p.color} size={16} />
|
||||
<span className="min-w-0 truncate text-[13px] font-bold">{p.name}</span>
|
||||
<span className="ml-auto flex-none text-[11px] text-ink-mute">{formatSpacing(p.spacingCm, unit)} apart</span>
|
||||
</button>
|
||||
{choices.length > 0 && <LotChooser lots={choices} value={armedLotId} onChange={setArmedLotId} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{plants.length === 0 && <p className="px-1 text-xs text-ink-mute">No plants in the catalog yet.</p>}
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-2">
|
||||
{armedPlant && <FillRow plant={armedPlant} busy={filling} onFill={onFill} />}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{focusedPlopCount > 0 && (
|
||||
<Button variant="ghost" icon="eraser" iconSize={13} className="px-2.5 text-[13px] text-accent-700" onClick={onClear}>
|
||||
Clear the bed ({focusedPlopCount})
|
||||
</Button>
|
||||
)}
|
||||
{canScan && (
|
||||
<Button variant="ghost" icon="camera" iconSize={13} className="px-2.5 text-[13px]" onClick={onScan}>
|
||||
Scan a packet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="px-1 text-xs leading-relaxed text-ink-mute">
|
||||
{!canEdit ? 'You can look but not change this garden.' : `${focused.name} isn't plantable — turn that on in the inspector's details.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Which packet a planting comes out of, when a plant has more than one. */
|
||||
export function LotChooser({
|
||||
lots,
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
lots: SeedLot[]
|
||||
value: number | null
|
||||
onChange: (lotId: number | null) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-1', compact ? 'items-center' : 'pl-2')}>
|
||||
<span className="w-full text-[11px] font-bold text-ink-mute">Which packet?</span>
|
||||
{lots.map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
className="chip px-3 py-1 text-xs"
|
||||
aria-pressed={value === lot.id}
|
||||
onClick={() => onChange(lot.id)}
|
||||
title={`${formatQuantity(lot.remaining)} of ${formatQuantity(lot.quantity)} ${lot.unit} left`}
|
||||
>
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="chip px-3 py-1 text-xs" aria-pressed={value === null} onClick={() => onChange(null)}>
|
||||
No particular one
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fill the whole bed with the armed plant — rows you could plant from, or
|
||||
* clumps for a quick sketch (#77). */
|
||||
export function FillRow({ plant, busy, onFill }: { plant: Plant; busy: boolean; onFill: (layout: FillLayout) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-[18px] border border-divider bg-bg px-2.5 py-2">
|
||||
<span className="text-[11px] font-bold text-ink-mute">Fill the bed with {plant.name.toLowerCase()}:</span>
|
||||
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('grid')} title="Individual plants at true spacing">
|
||||
rows
|
||||
</button>
|
||||
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('clump')} title="Fat clumps — a quick sketch">
|
||||
clumps
|
||||
</button>
|
||||
{busy && <span className="text-[11px] text-ink-mute">filling…</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { UndoOutcome, UndoTarget, useUndo } from '@/lib/history'
|
||||
|
||||
/**
|
||||
* Undo, plus what happened. Paired with useUndo so the history list and the
|
||||
* agent turn's inline undo (#57) behave identically — including the part that
|
||||
* actually matters, which is reporting a partial result honestly rather than as
|
||||
* a success or a failure.
|
||||
*/
|
||||
export function UndoButton({
|
||||
changeSet,
|
||||
undo,
|
||||
className,
|
||||
label = 'Undo',
|
||||
}: {
|
||||
changeSet: UndoTarget
|
||||
undo: ReturnType<typeof useUndo>
|
||||
className?: string
|
||||
label?: string
|
||||
}) {
|
||||
const outcome = undo.outcomeFor(changeSet.id)
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-1', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
disabled={outcome?.tone === 'pending'}
|
||||
onClick={() => undo.undo(changeSet)}
|
||||
>
|
||||
{outcome?.tone === 'pending' ? 'Undoing…' : label}
|
||||
</Button>
|
||||
{outcome && outcome.tone !== 'pending' && <OutcomeNote outcome={outcome} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
|
||||
ok: 'text-muted',
|
||||
partial: 'text-amber-700 dark:text-amber-400',
|
||||
error: 'text-red-700 dark:text-red-400',
|
||||
}
|
||||
|
||||
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
||||
if (outcome.tone === 'pending') return null
|
||||
// No text alignment of its own: the container decides. The history list stacks
|
||||
// this to the right of an entry, the chat panel puts it under a left-aligned
|
||||
// message, and a hard-coded text-right made the chat's copy read against its
|
||||
// own column.
|
||||
return (
|
||||
<p role="status" className={cn('text-xs', TONE_CLASS[outcome.tone])}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Arming a plant for placement: shared by the desktop toolkit and the phone
|
||||
// strip so both pick the same lot rules and order their lists the same way.
|
||||
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { recentlyPlantedIds, type EditorPlanting } from '@/lib/plantings'
|
||||
import { attributableLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
/** The catalog ordered for planting: what this garden has been planted with
|
||||
* most recently first (so "more of the same" is the first chip), then the rest
|
||||
* alphabetically; narrowed by a name query. */
|
||||
export function orderedPlants(plants: readonly Plant[], plantings: readonly EditorPlanting[], query: string): Plant[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
const byId = new Map(plants.map((p) => [p.id, p]))
|
||||
const recent = recentlyPlantedIds([...plantings])
|
||||
.map((id) => byId.get(id))
|
||||
.filter((p): p is Plant => !!p)
|
||||
const seen = new Set(recent.map((p) => p.id))
|
||||
const rest = [...plants].filter((p) => !seen.has(p.id)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const all = [...recent, ...rest]
|
||||
return q ? all.filter((p) => p.name.toLowerCase().includes(q)) : all
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm a plant (tap again to disarm). One attributable lot attributes itself;
|
||||
* several leave the lot unset so the caller can ask which packet — never a
|
||||
* guess, since a planting's lot can't be changed after the fact. Arming a plant
|
||||
* disarms any kind and drops the selection, as the prototype does.
|
||||
*/
|
||||
export function toggleArmedPlant(plant: Plant, lotsByPlant: Map<number, SeedLot[]>): void {
|
||||
const st = useEditorStore.getState()
|
||||
if (st.armedPlant?.id === plant.id) {
|
||||
st.setArmedPlant(null)
|
||||
return
|
||||
}
|
||||
const lots = attributableLots(lotsByPlant.get(plant.id) ?? [])
|
||||
st.setArmedKind(null)
|
||||
st.setSel(null)
|
||||
st.setArmedPlant(plant, lots.length === 1 ? lots[0].id : null)
|
||||
}
|
||||
|
||||
/** The lots a just-armed plant could be attributed to, when that's a question
|
||||
* worth asking (more than one). */
|
||||
export function lotChoices(plant: Plant | null, lotsByPlant: Map<number, SeedLot[]>): SeedLot[] {
|
||||
if (!plant) return []
|
||||
const lots = attributableLots(lotsByPlant.get(plant.id) ?? [])
|
||||
return lots.length > 1 ? lots : []
|
||||
}
|
||||
|
||||
export function toggleArmedKind(kind: string): void {
|
||||
const st = useEditorStore.getState()
|
||||
const armed = st.armedKind === kind
|
||||
st.setArmedKind(armed ? null : kind)
|
||||
st.setArmedPlant(null)
|
||||
st.setSel(null)
|
||||
st.setGhost(null)
|
||||
}
|
||||
+54
-12
@@ -1,27 +1,26 @@
|
||||
import type { ObjectShapeKind } from './types'
|
||||
|
||||
// The seven placeable object kinds with their palette presentation and sensible
|
||||
// default sizes (cm), per the #11 brief.
|
||||
// The seven placeable object kinds: the design's default sizes (cm — a 3′×6′
|
||||
// bed, a 1′4″ grow bag…), whether plants go in them, and the stacking order at
|
||||
// placement (paths/structures/in-ground under beds, trees floating above).
|
||||
export interface KindDef {
|
||||
kind: string
|
||||
label: string
|
||||
icon: string
|
||||
shape: ObjectShapeKind
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
// Default z-index at placement. Paths/structures/in-ground sit under beds (0);
|
||||
// beds and containers sit at 1; trees float above (2). Explicit z still wins.
|
||||
plantable: boolean
|
||||
defaultZ: number
|
||||
}
|
||||
|
||||
export const OBJECT_KINDS: KindDef[] = [
|
||||
{ kind: 'bed', label: 'Bed', icon: '▭', shape: 'rect', widthCm: 120, heightCm: 240, defaultZ: 1 },
|
||||
{ kind: 'grow_bag', label: 'Grow bag', icon: '🛍️', shape: 'circle', widthCm: 40, heightCm: 40, defaultZ: 1 },
|
||||
{ kind: 'container', label: 'Container', icon: '🪴', shape: 'circle', widthCm: 60, heightCm: 60, defaultZ: 1 },
|
||||
{ kind: 'in_ground', label: 'In-ground', icon: '🟫', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||
{ kind: 'tree', label: 'Tree', icon: '🌳', shape: 'circle', widthCm: 300, heightCm: 300, defaultZ: 2 },
|
||||
{ kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300, defaultZ: 0 },
|
||||
{ kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||
{ kind: 'bed', label: 'Bed', shape: 'rect', widthCm: 91, heightCm: 183, plantable: true, defaultZ: 1 },
|
||||
{ kind: 'grow_bag', label: 'Grow bag', shape: 'circle', widthCm: 40, heightCm: 40, plantable: true, defaultZ: 1 },
|
||||
{ kind: 'container', label: 'Container', shape: 'circle', widthCm: 60, heightCm: 60, plantable: true, defaultZ: 1 },
|
||||
{ kind: 'in_ground', label: 'In-ground', shape: 'rect', widthCm: 200, heightCm: 200, plantable: true, defaultZ: 0 },
|
||||
{ kind: 'tree', label: 'Tree', shape: 'circle', widthCm: 300, heightCm: 300, plantable: false, defaultZ: 2 },
|
||||
{ kind: 'path', label: 'Path', shape: 'rect', widthCm: 100, heightCm: 300, plantable: false, defaultZ: 0 },
|
||||
{ kind: 'structure', label: 'Structure', shape: 'rect', widthCm: 200, heightCm: 200, plantable: false, defaultZ: 0 },
|
||||
]
|
||||
|
||||
export function kindDef(kind: string): KindDef | undefined {
|
||||
@@ -32,3 +31,46 @@ export function kindDef(kind: string): KindDef | undefined {
|
||||
export function objectDisplayName(o: { name: string; kind: string }): string {
|
||||
return o.name || kindDef(o.kind)?.label || 'Object'
|
||||
}
|
||||
|
||||
/** The plural noun for a count of a kind: "9 beds", "3 grow bags". */
|
||||
export function kindPlural(kind: string, n: number): string {
|
||||
const label = (kindDef(kind)?.label ?? kind.replace(/_/g, ' ')).toLowerCase()
|
||||
if (n === 1) return `1 ${label}`
|
||||
return `${n} ${label === 'in-ground' ? 'in-ground plots' : label.endsWith('h') ? `${label}es` : `${label}s`}`
|
||||
}
|
||||
|
||||
/** How a kind draws: fill + stroke from the canvas tokens (so they flip with
|
||||
* the theme), and a dash pattern in world cm for the soft-edged kinds. */
|
||||
export interface KindStyle {
|
||||
fill: string
|
||||
stroke: string
|
||||
dash?: string
|
||||
}
|
||||
|
||||
const STYLES: Record<string, KindStyle> = {
|
||||
bed: { fill: 'var(--p-bed-fill)', stroke: 'var(--p-bed-stroke)' },
|
||||
in_ground: { fill: 'var(--p-ing-fill)', stroke: 'var(--p-ing-stroke)', dash: '10 7' },
|
||||
path: { fill: 'var(--p-path-fill)', stroke: 'var(--p-path-stroke)', dash: '3 8' },
|
||||
grow_bag: { fill: 'var(--p-bag-fill)', stroke: 'var(--p-bag-stroke)' },
|
||||
container: { fill: 'var(--p-bkt-fill)', stroke: 'var(--p-bkt-stroke)' },
|
||||
tree: { fill: 'var(--p-tree-fill)', stroke: 'var(--p-tree-stroke)', dash: '12 8' },
|
||||
structure: { fill: 'var(--p-str-fill)', stroke: 'var(--p-str-stroke)' },
|
||||
}
|
||||
|
||||
export function kindStyle(kind: string): KindStyle {
|
||||
return STYLES[kind] ?? STYLES.structure
|
||||
}
|
||||
|
||||
/** An object's style: its own color override (a darker mix of it for the
|
||||
* stroke) or its kind's. */
|
||||
export function objectStyle(o: { kind: string; color?: string | null }): KindStyle {
|
||||
if (o.color) {
|
||||
return { fill: o.color, stroke: `color-mix(in srgb, ${o.color} 65%, #201e1d)`, dash: kindStyle(o.kind).dash }
|
||||
}
|
||||
return kindStyle(o.kind)
|
||||
}
|
||||
|
||||
/** The corner radius the design gives a rect of width w (cm). */
|
||||
export function rectRadius(widthCm: number): number {
|
||||
return Math.min(14, widthCm * 0.14)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useGardenYears } from '@/lib/objects'
|
||||
import { parsePlanName, planGardensOf } from '@/lib/plan'
|
||||
import { useEditorStore } from './store'
|
||||
|
||||
export interface SeasonOption {
|
||||
value: string
|
||||
/** "2026" / "2027 plan" / the base garden's name. */
|
||||
label: string
|
||||
/** The phone chip: "26" / "27 plan". */
|
||||
short: string
|
||||
kind: 'year' | 'plan' | 'base'
|
||||
}
|
||||
|
||||
/**
|
||||
* The season control. Years with planting data come from the API (the current
|
||||
* year is always among them): the current year is the live, editable garden;
|
||||
* any other is that season's read-only record. A "YYYY plan" entry is a whole-
|
||||
* garden copy named "<this garden> — YYYY" (see lib/plan.ts) and opens it; from
|
||||
* inside a plan, the control offers the way back to the garden it came from.
|
||||
*/
|
||||
export function useSeasons(garden: Garden, gardens: Garden[] | undefined) {
|
||||
const navigate = useNavigate()
|
||||
const years = useGardenYears(garden.id)
|
||||
const seasonYear = useEditorStore((s) => s.seasonYear)
|
||||
const setSeasonYear = useEditorStore((s) => s.setSeasonYear)
|
||||
const now = new Date().getFullYear()
|
||||
const plan = parsePlanName(garden.name)
|
||||
const baseGarden = plan ? gardens?.find((g) => g.name.trim() === plan.base) : undefined
|
||||
|
||||
let options: SeasonOption[]
|
||||
let value: string
|
||||
let banner: string | null = null
|
||||
if (plan && baseGarden) {
|
||||
options = [
|
||||
{ value: `garden:${baseGarden.id}`, label: baseGarden.name, short: '←', kind: 'base' },
|
||||
{ value: 'this', label: `${plan.year} plan`, short: `${String(plan.year).slice(2)} plan`, kind: 'plan' },
|
||||
]
|
||||
value = 'this'
|
||||
banner = `Editing the ${plan.year} plan — a separate copy. ${baseGarden.name} stays untouched.`
|
||||
} else {
|
||||
const ys = new Set<number>(years.data ?? [])
|
||||
ys.add(now)
|
||||
options = [...ys]
|
||||
.sort((a, b) => a - b)
|
||||
.map((y) => ({ value: String(y), label: String(y), short: String(y).slice(2), kind: 'year' as const }))
|
||||
for (const g of planGardensOf(garden.name, gardens ?? [])) {
|
||||
options.push({ value: `garden:${g.id}`, label: `${g.year} plan`, short: `${String(g.year).slice(2)} plan`, kind: 'plan' })
|
||||
}
|
||||
value = seasonYear != null ? String(seasonYear) : String(now)
|
||||
if (seasonYear != null) banner = seasonYear < now ? `${seasonYear} is a past season — read-only.` : `Viewing ${seasonYear} — read-only.`
|
||||
}
|
||||
|
||||
const select = (v: string) => {
|
||||
if (v === 'this') return
|
||||
if (v.startsWith('garden:')) {
|
||||
navigate({ to: '/gardens/$gardenId', params: { gardenId: v.slice('garden:'.length) } })
|
||||
return
|
||||
}
|
||||
const y = Number(v)
|
||||
if (Number.isInteger(y)) setSeasonYear(y === now ? null : y)
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
const i = options.findIndex((o) => o.value === value)
|
||||
const next = options[(i + 1) % options.length]
|
||||
if (next) select(next.value)
|
||||
}
|
||||
|
||||
const current = options.find((o) => o.value === value)
|
||||
return { options, value, select, cycle, banner, short: current?.short ?? String(now).slice(2), isPlan: !!(plan && baseGarden) }
|
||||
}
|
||||
+123
-17
@@ -1,24 +1,130 @@
|
||||
// Shared editor UI constants + tiny helpers used across the object/plop markers
|
||||
// and overlays, so colors, handle sizes, and the object-local transform stay in
|
||||
// one place instead of drifting between files.
|
||||
// Shared editor constants + tiny geometry helpers, so the canvas, the
|
||||
// inspectors and the page agree on the same numbers.
|
||||
|
||||
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
|
||||
// Whether the primary pointer is a fingertip rather than a mouse — the one signal
|
||||
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
|
||||
// the editor), so they can't disagree about what "touch" means. Read once at
|
||||
// load; a device doesn't switch its primary pointer mid-session, and the optional
|
||||
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
|
||||
export const isCoarsePointer =
|
||||
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
|
||||
// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can
|
||||
// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but
|
||||
// frustrating for a thumb (#104).
|
||||
export const HANDLE_PX = isCoarsePointer ? 22 : 12
|
||||
export const MIN_RADIUS_CM = 1 // smallest plop radius
|
||||
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
|
||||
import { localToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/** Object drags snap their center to a 3-inch grid (unless the garden has its
|
||||
* own snap grid turned on). */
|
||||
export const SNAP_CM = 7.62
|
||||
/** Camera scale bounds, px per cm. */
|
||||
export const MIN_SCALE = 0.12
|
||||
export const MAX_SCALE = 8
|
||||
/** The container width below which the editor renders its phone chrome. */
|
||||
export const PHONE_BREAKPOINT = 760
|
||||
/** Smallest an object may be resized to (cm). */
|
||||
export const MIN_OBJECT_CM = 5
|
||||
/** Focus mode dims everything that isn't the focused bed. */
|
||||
export const DIM_OBJECT = 0.3
|
||||
export const DIM_PLOP = 0.22
|
||||
|
||||
export { type Point }
|
||||
|
||||
/** SVG transform placing content in an object's local frame: its center point
|
||||
* then its clockwise rotation. Plops and overlays render inside this. */
|
||||
export function objectTransform(o: { xCm: number; yCm: number; rotationDeg: number }): string {
|
||||
return `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
|
||||
}
|
||||
|
||||
export function toLocal(o: EditorObject, w: Point): Point {
|
||||
return worldToLocal(w, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||
}
|
||||
|
||||
export function toWorld(o: EditorObject, l: Point): Point {
|
||||
return localToWorld(l, { x: o.xCm, y: o.yCm }, o.rotationDeg)
|
||||
}
|
||||
|
||||
/** Half the height of an object's screen-aligned bounding box once rotated —
|
||||
* where a label above or below it should sit. */
|
||||
export function rotatedHalfHeight(o: EditorObject): number {
|
||||
const a = (o.rotationDeg * Math.PI) / 180
|
||||
return (Math.abs(o.widthCm * Math.sin(a)) + Math.abs(o.heightCm * Math.cos(a))) / 2
|
||||
}
|
||||
|
||||
/** Whether a world point is inside an object (its own frame). */
|
||||
export function objectContains(o: EditorObject, w: Point): boolean {
|
||||
const l = toLocal(o, w)
|
||||
if (o.shape === 'circle') {
|
||||
const rx = o.widthCm / 2
|
||||
const ry = o.heightCm / 2
|
||||
return rx > 0 && ry > 0 && (l.x * l.x) / (rx * rx) + (l.y * l.y) / (ry * ry) <= 1
|
||||
}
|
||||
return Math.abs(l.x) <= o.widthCm / 2 && Math.abs(l.y) <= o.heightCm / 2
|
||||
}
|
||||
|
||||
/** The topmost object under a world point (the list is in draw order). */
|
||||
export function objectAt(objects: readonly EditorObject[], w: Point): EditorObject | null {
|
||||
for (let i = objects.length - 1; i >= 0; i--) if (objectContains(objects[i], w)) return objects[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a plop's center inside its object, letting the patch overhang the edge
|
||||
* by up to half its radius — the spacing rule (DESIGN.md): a bed edge is
|
||||
* nobody's neighbour, so the outer plant owes it half a spacing, not a whole.
|
||||
*/
|
||||
export function clampPlopLocal(o: EditorObject, p: Point, r: number): Point {
|
||||
const hx = Math.max(0, o.widthCm / 2 - r / 2)
|
||||
const hy = Math.max(0, o.heightCm / 2 - r / 2)
|
||||
if (o.shape === 'circle') {
|
||||
// Ellipse: scale the point into the unit circle and pull it back to the rim.
|
||||
const nx = hx > 0 ? p.x / hx : 0
|
||||
const ny = hy > 0 ? p.y / hy : 0
|
||||
const d = Math.hypot(nx, ny)
|
||||
if (d <= 1) return p
|
||||
return { x: (nx / d) * hx, y: (ny / d) * hy }
|
||||
}
|
||||
return { x: Math.max(-hx, Math.min(hx, p.x)), y: Math.max(-hy, Math.min(hy, p.y)) }
|
||||
}
|
||||
|
||||
/** Snap a bed-local point to the bed's own grid (anchored at its top-left
|
||||
* corner, matching the lines the canvas draws), when the bed snaps. */
|
||||
export function snapPlopLocal(o: EditorObject, p: Point): Point {
|
||||
if (!o.snapToGrid || !(o.gridSizeCm > 0)) return p
|
||||
const step = o.gridSizeCm
|
||||
const hw = o.widthCm / 2
|
||||
const hh = o.heightCm / 2
|
||||
return {
|
||||
x: -hw + Math.round((p.x + hw) / step) * step,
|
||||
y: -hh + Math.round((p.y + hh) / step) * step,
|
||||
}
|
||||
}
|
||||
|
||||
/** The count a plop shows: its override, else derived live from its radius. */
|
||||
export function plopCount(p: EditorPlanting, plant: Plant | undefined): number {
|
||||
if (p.count != null) return p.count
|
||||
return plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
||||
}
|
||||
|
||||
/** The radius a fresh tap-placed plop gets: half the plant's spacing — one
|
||||
* plant per patch, the grid layout you could plant from. Fill (rows/clumps)
|
||||
* covers bulk. */
|
||||
export function defaultPlopRadius(plant: Plant): number {
|
||||
return Math.max(1, plant.spacingCm / 2)
|
||||
}
|
||||
|
||||
/** Whether keyboard focus is in a text control, so shortcuts stand down. */
|
||||
export function isTyping(): boolean {
|
||||
const el = document.activeElement
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable
|
||||
}
|
||||
|
||||
/** "3m ago" / "yesterday" for a UTC timestamp. */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = (Date.now() - then) / 1000
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
+90
-138
@@ -1,163 +1,115 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Viewport } from '@/lib/geometry'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
|
||||
// selection, the object being live-edited mid-gesture, and the palette kind
|
||||
// armed for tap-to-place. All server state stays in react-query.
|
||||
// Ephemeral editor state only (DESIGN § State): the camera, the selection, the
|
||||
// focused bed, what's armed for placement, the rail tab / phone mode, and any
|
||||
// in-flight drag geometry. All server state stays in react-query.
|
||||
|
||||
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
||||
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
||||
/** The camera: world (garden cm) → screen is translate(tx,ty) scale(s). */
|
||||
export interface Viewport {
|
||||
tx: number
|
||||
ty: number
|
||||
s: number
|
||||
}
|
||||
|
||||
// The editor's one primary activity (#99). On mobile this is the segmented
|
||||
// control at the bottom of the screen; it decides which tools dock there —
|
||||
// placing fixtures, placing plants, the journal, or the assistant — so the four
|
||||
// activities stop competing for the same cramped strip. Desktop keeps its
|
||||
// side-column layout and treats this as a lighter hint.
|
||||
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
|
||||
export type Selection = { type: 'object'; id: number } | { type: 'plop'; id: number }
|
||||
|
||||
// Where the editor starts, and where it returns on a garden switch.
|
||||
export const DEFAULT_MODE: EditorMode = 'fixtures'
|
||||
/** The right-hand rail's tabs (desktop). */
|
||||
export type RailTab = 'plot' | 'journal' | 'history' | 'chat'
|
||||
|
||||
/** The phone's primary mode — which tools dock under the canvas. */
|
||||
export type PhoneMode = 'build' | 'plants' | 'journal' | 'chat'
|
||||
|
||||
/** Which thing the journal list is narrowed to, when it is. The composer
|
||||
* attaches to the selection regardless; this is the reading filter. */
|
||||
export type JournalScope = { type: 'object'; id: number } | { type: 'plop'; id: number }
|
||||
|
||||
interface EditorState {
|
||||
viewport: Viewport
|
||||
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
||||
vp: Viewport
|
||||
/** True while the camera is animating (fit/focus); drags and zooms clear it. */
|
||||
anim: boolean
|
||||
setVp: (vp: Viewport, anim: boolean) => void
|
||||
setAnim: (anim: boolean) => void
|
||||
|
||||
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
|
||||
// last using is not a property of the garden.
|
||||
mode: EditorMode
|
||||
setMode: (mode: EditorMode) => void
|
||||
sel: Selection | null
|
||||
setSel: (sel: Selection | null) => void
|
||||
|
||||
// The selected object OR plop (mutually exclusive; selecting one clears the
|
||||
// other). selectedId is a garden object; selectedPlantingId is a plop.
|
||||
selectedId: number | null
|
||||
select: (id: number | null) => void
|
||||
focusId: number | null
|
||||
setFocus: (id: number | null) => void
|
||||
|
||||
selectedPlantingId: number | null
|
||||
selectPlanting: (id: number | null) => void
|
||||
|
||||
focusedObjectId: number | null
|
||||
setFocusedObject: (id: number | null) => void
|
||||
|
||||
// Which rail tab is showing, or null when the rail is closed. Selecting an
|
||||
// object switches this to 'inspector' (see GardenEditorPage), so editing never
|
||||
// starts with a click on the rail itself.
|
||||
railTab: string | null
|
||||
setRailTab: (tab: string | null) => void
|
||||
|
||||
// Which season the canvas is showing: null is "now" (live and editable), a
|
||||
// year is a read-only view of what was in the ground that year. Ephemeral —
|
||||
// which year you were last looking at is not a property of the garden.
|
||||
seasonYear: number | null
|
||||
setSeasonYear: (year: number | null) => void
|
||||
|
||||
// Which bed the journal tab is filtered to, or null for the whole garden.
|
||||
// Separate from selectedId deliberately: you can scope the journal to a bed
|
||||
// and then select something else without the list moving under you.
|
||||
journalObjectId: number | null
|
||||
setJournalObjectId: (id: number | null) => void
|
||||
|
||||
// Which single plop the journal is filtered to, if any — the parallel of
|
||||
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
|
||||
// (the setters clear each other), so the journal filter is never ambiguous.
|
||||
journalPlantingId: number | null
|
||||
setJournalPlantingId: (id: number | null) => void
|
||||
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
// Which seed lot placements should be attributed to, when the armed plant has
|
||||
// one worth naming. Cleared with the plant.
|
||||
armedLotId: number | null
|
||||
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
|
||||
|
||||
// During a move/resize/rotate, the object's live geometry is held here so the
|
||||
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||
liveObject: EditorObject | null
|
||||
setLiveObject: (o: EditorObject | null) => void
|
||||
|
||||
// The same, for a plop being moved/resized.
|
||||
livePlanting: EditorPlanting | null
|
||||
setLivePlanting: (p: EditorPlanting | null) => void
|
||||
|
||||
// The palette kind armed for tap-to-place (mobile-friendly; also set while
|
||||
// dragging a kind from the palette). null when nothing is armed.
|
||||
armedKind: string | null
|
||||
setArmedKind: (kind: string | null) => void
|
||||
|
||||
// True while an object move/resize/rotate is in progress, so the viewport's
|
||||
// pan gesture stands down (both listen on the same svg).
|
||||
objectDragging: boolean
|
||||
setObjectDragging: (v: boolean) => void
|
||||
armedPlant: Plant | null
|
||||
/** Which seed lot placements are attributed to, when one is worth naming. */
|
||||
armedLotId: number | null
|
||||
setArmedPlant: (plant: Plant | null, lotId?: number | null) => void
|
||||
setArmedLotId: (lotId: number | null) => void
|
||||
|
||||
// Clear all transient (non-persisted) editor state at once — selection, focus,
|
||||
// armed plant/kind, and any in-flight live geometry — when entering a garden
|
||||
// view fresh (e.g. the public read-only page on mount). The viewport is left
|
||||
// alone; the canvas re-fits it from the loaded garden.
|
||||
resetTransient: () => void
|
||||
/** Ghost preview position (world cm, snapped) while a kind is armed. */
|
||||
ghost: { x: number; y: number } | null
|
||||
setGhost: (g: { x: number; y: number } | null) => void
|
||||
|
||||
/** null = now (live, editable); a year = that season, read-only. */
|
||||
seasonYear: number | null
|
||||
setSeasonYear: (year: number | null) => void
|
||||
|
||||
tab: RailTab
|
||||
setTab: (tab: RailTab) => void
|
||||
|
||||
mode: PhoneMode
|
||||
setMode: (mode: PhoneMode) => void
|
||||
|
||||
journalScope: JournalScope | null
|
||||
setJournalScope: (scope: JournalScope | null) => void
|
||||
|
||||
// During a drag/resize the live geometry is held here so the canvas renders
|
||||
// it instantly; the PATCH fires once on release.
|
||||
liveObject: EditorObject | null
|
||||
setLiveObject: (o: EditorObject | null) => void
|
||||
livePlanting: EditorPlanting | null
|
||||
setLivePlanting: (p: EditorPlanting | null) => void
|
||||
|
||||
/** Everything transient, cleared when entering or leaving a garden. The
|
||||
* camera is left alone; the canvas re-fits from the loaded garden. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const TRANSIENT = {
|
||||
sel: null,
|
||||
focusId: null,
|
||||
armedKind: null,
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
ghost: null,
|
||||
seasonYear: null,
|
||||
tab: 'plot' as RailTab,
|
||||
mode: 'build' as PhoneMode,
|
||||
journalScope: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
viewport: { tx: 0, ty: 0, scale: 1 },
|
||||
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||
vp: { tx: 40, ty: 40, s: 0.5 },
|
||||
anim: false,
|
||||
setVp: (vp, anim) => set({ vp, anim }),
|
||||
setAnim: (anim) => set({ anim }),
|
||||
|
||||
mode: DEFAULT_MODE,
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
selectedId: null,
|
||||
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
|
||||
|
||||
selectedPlantingId: null,
|
||||
selectPlanting: (id) => set({ selectedPlantingId: id, selectedId: null }),
|
||||
|
||||
focusedObjectId: null,
|
||||
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
||||
|
||||
railTab: null,
|
||||
setRailTab: (tab) => set({ railTab: tab }),
|
||||
|
||||
seasonYear: null,
|
||||
...TRANSIENT,
|
||||
setSel: (sel) => set({ sel }),
|
||||
setFocus: (id) => set({ focusId: id }),
|
||||
setArmedKind: (kind) => set({ armedKind: kind, ghost: kind ? undefined : null } as Partial<EditorState>),
|
||||
setArmedPlant: (plant, lotId = null) => set({ armedPlant: plant, armedLotId: plant ? lotId : null }),
|
||||
setArmedLotId: (lotId) => set({ armedLotId: lotId }),
|
||||
setGhost: (ghost) => set({ ghost }),
|
||||
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||
|
||||
journalObjectId: null,
|
||||
// Scoping to a bed clears any plop scope, so only one is ever active.
|
||||
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
|
||||
|
||||
journalPlantingId: null,
|
||||
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
|
||||
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||
|
||||
liveObject: null,
|
||||
setTab: (tab) => set({ tab }),
|
||||
setMode: (mode) => set({ mode }),
|
||||
setJournalScope: (scope) => set({ journalScope: scope }),
|
||||
setLiveObject: (o) => set({ liveObject: o }),
|
||||
|
||||
livePlanting: null,
|
||||
setLivePlanting: (p) => set({ livePlanting: p }),
|
||||
|
||||
armedKind: null,
|
||||
setArmedKind: (kind) => set({ armedKind: kind }),
|
||||
|
||||
objectDragging: false,
|
||||
setObjectDragging: (v) => set({ objectDragging: v }),
|
||||
|
||||
resetTransient: () =>
|
||||
set({
|
||||
selectedId: null,
|
||||
selectedPlantingId: null,
|
||||
focusedObjectId: null,
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
railTab: null,
|
||||
seasonYear: null,
|
||||
journalObjectId: null,
|
||||
journalPlantingId: null,
|
||||
mode: DEFAULT_MODE,
|
||||
}),
|
||||
reset: () => set({ ...TRANSIENT }),
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback } from 'react'
|
||||
import { totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
|
||||
|
||||
/** The newest change set still in effect — not already reverted, and not itself
|
||||
* an undo (undoing an undo is a redo, which the History tab offers per entry). */
|
||||
function latestUndoable(sets: ChangeSet[]): ChangeSet | null {
|
||||
return sets.find((cs) => cs.revertedById == null && cs.revertsId == null && totalChanges(cs) > 0) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's one-button Undo. It re-reads the history before choosing what
|
||||
* to revert: the cached list can trail the canvas (a placement that just landed
|
||||
* isn't in it yet), and undoing the step *before* the one you meant is the
|
||||
* worst thing an undo button can do. Shares the useUndo instance with the
|
||||
* History tab so outcomes are reported identically.
|
||||
*/
|
||||
export function useUndoLast(gardenId: number, enabled: boolean) {
|
||||
const history = useGardenHistory(gardenId, enabled)
|
||||
const undo = useUndo(gardenId)
|
||||
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
|
||||
const target = latestUndoable(sets)
|
||||
const outcome = target ? undo.outcomeFor(target.id) : undefined
|
||||
|
||||
const undoLast = useCallback(async () => {
|
||||
const fresh = await history.refetch()
|
||||
const list = fresh.data?.pages.flatMap((p) => p.changeSets) ?? sets
|
||||
const t = latestUndoable(list)
|
||||
if (t) undo.undo(t)
|
||||
}, [history, undo, sets])
|
||||
|
||||
return {
|
||||
history,
|
||||
undo,
|
||||
sets,
|
||||
target,
|
||||
canUndo: !!target && outcome?.tone !== 'pending',
|
||||
undoLast,
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import { useGesture } from '@use-gesture/react'
|
||||
import {
|
||||
easeInOutCubic,
|
||||
lerp,
|
||||
zoomToFitRect,
|
||||
zoomViewportAt,
|
||||
type Point,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from '@/lib/geometry'
|
||||
import { MAX_SCALE, MIN_SCALE, useEditorStore } from './store'
|
||||
|
||||
const WHEEL_SENSITIVITY = 0.0015 // exponential zoom per wheel delta unit
|
||||
const FIT_PADDING = 32 // px margin around a fitted rect
|
||||
const FIT_DURATION_MS = 350
|
||||
|
||||
/**
|
||||
* Wires pan/zoom/pinch gestures onto the svg (via @use-gesture) and returns an
|
||||
* animated `fitToRect`. Wheel and pinch zoom toward the pointer; drag on empty
|
||||
* space pans (object dragging in #11 stops propagation). Scale is clamped to
|
||||
* [MIN_SCALE, MAX_SCALE]. The svg must set touch-action:none so the browser
|
||||
* doesn't hijack the gestures.
|
||||
*/
|
||||
export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
|
||||
const setViewport = useEditorStore((s) => s.setViewport)
|
||||
const animRef = useRef<number | null>(null)
|
||||
|
||||
const cancelAnim = useCallback(() => {
|
||||
if (animRef.current != null) {
|
||||
cancelAnimationFrame(animRef.current)
|
||||
animRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Client (page) coords → coords within the svg, for anchoring zoom.
|
||||
const clientToCanvas = useCallback(
|
||||
(clientX: number, clientY: number): Point => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY }
|
||||
},
|
||||
[svgRef],
|
||||
)
|
||||
|
||||
useGesture(
|
||||
{
|
||||
onDragStart: cancelAnim,
|
||||
onDrag: ({ delta: [dx, dy], pinching, cancel }) => {
|
||||
// An object move/resize/rotate owns this drag; don't also pan.
|
||||
if (useEditorStore.getState().objectDragging) return
|
||||
if (pinching) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
setViewport((vp) => ({ ...vp, tx: vp.tx + dx, ty: vp.ty + dy }))
|
||||
},
|
||||
onWheelStart: cancelAnim,
|
||||
onWheel: ({ event, delta: [, dy] }) => {
|
||||
event.preventDefault()
|
||||
if (!Number.isFinite(dy)) return // never let a bad delta corrupt the viewport
|
||||
const p = clientToCanvas(event.clientX, event.clientY)
|
||||
setViewport((vp) => zoomViewportAt(vp, p, vp.scale * Math.exp(-dy * WHEEL_SENSITIVITY), MIN_SCALE, MAX_SCALE))
|
||||
},
|
||||
onPinchStart: cancelAnim,
|
||||
onPinch: ({ origin: [ox, oy], offset: [scale] }) => {
|
||||
if (!Number.isFinite(scale)) return
|
||||
const p = clientToCanvas(ox, oy)
|
||||
setViewport((vp) => zoomViewportAt(vp, p, scale, MIN_SCALE, MAX_SCALE))
|
||||
},
|
||||
},
|
||||
{
|
||||
target: svgRef,
|
||||
eventOptions: { passive: false }, // so onWheel can preventDefault
|
||||
drag: { filterTaps: true },
|
||||
pinch: {
|
||||
// Seed the pinch offset with the current scale so offset[0] is absolute.
|
||||
from: () => [useEditorStore.getState().viewport.scale, 0],
|
||||
scaleBounds: { min: MIN_SCALE, max: MAX_SCALE },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const fitToRect = useCallback(
|
||||
(rect: Rect, canvasSize: Size) => {
|
||||
cancelAnim()
|
||||
const from = useEditorStore.getState().viewport
|
||||
const to = zoomToFitRect(rect, canvasSize, FIT_PADDING, MIN_SCALE, MAX_SCALE)
|
||||
const start = performance.now()
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / FIT_DURATION_MS)
|
||||
const e = easeInOutCubic(t)
|
||||
setViewport({ tx: lerp(from.tx, to.tx, e), ty: lerp(from.ty, to.ty, e), scale: lerp(from.scale, to.scale, e) })
|
||||
animRef.current = t < 1 ? requestAnimationFrame(step) : null
|
||||
}
|
||||
animRef.current = requestAnimationFrame(step)
|
||||
},
|
||||
[cancelAnim, setViewport],
|
||||
)
|
||||
|
||||
useEffect(() => cancelAnim, [cancelAnim]) // stop any tween on unmount
|
||||
|
||||
return { fitToRect }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { monogramFor, monogramMap, speciesName } from './monogram'
|
||||
|
||||
describe('speciesName', () => {
|
||||
it('drops a variety suffix and a parenthetical', () => {
|
||||
expect(speciesName('Tomato — Cherokee Purple')).toBe('Tomato')
|
||||
expect(speciesName('Melon - Hale’s Best')).toBe('Melon')
|
||||
expect(speciesName('Garlic (Music)')).toBe('Garlic')
|
||||
expect(speciesName('Kale')).toBe('Kale')
|
||||
})
|
||||
})
|
||||
|
||||
describe('monogramMap', () => {
|
||||
it('gives a bare initial where it is unique', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 1, name: 'Garlic' },
|
||||
{ id: 2, name: 'Tomato' },
|
||||
])
|
||||
expect(m.get(1)).toBe('G')
|
||||
expect(m.get(2)).toBe('T')
|
||||
})
|
||||
|
||||
it('resolves a shared initial by catalog order, second letter lowercase', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 6, name: 'Tomato' },
|
||||
{ id: 26, name: 'Thyme' },
|
||||
{ id: 32, name: 'Marigold' },
|
||||
{ id: 29, name: 'Mint' },
|
||||
])
|
||||
expect(m.get(6)).toBe('T') // first into the catalog keeps the initial
|
||||
expect(m.get(26)).toBe('Th')
|
||||
expect(m.get(29)).toBe('M')
|
||||
expect(m.get(32)).toBe('Ma')
|
||||
})
|
||||
|
||||
it('takes the next free second letter, never repeating the initial', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 5, name: 'Pea' },
|
||||
{ id: 7, name: 'Pepper' },
|
||||
{ id: 24, name: 'Parsley' },
|
||||
])
|
||||
expect(m.get(5)).toBe('P')
|
||||
expect(m.get(7)).toBe('Pe')
|
||||
expect(m.get(24)).toBe('Pa')
|
||||
const n = monogramMap([
|
||||
{ id: 1, name: 'Pea' },
|
||||
{ id: 2, name: 'Peach' },
|
||||
{ id: 3, name: 'Pepper' },
|
||||
])
|
||||
expect(n.get(2)).toBe('Pe')
|
||||
expect(n.get(3)).toBe('Pr') // "Pp" would repeat the initial
|
||||
})
|
||||
|
||||
it('uses the species for varieties and keeps the plain one first', () => {
|
||||
const m = monogramMap([
|
||||
{ id: 6, name: 'Tomato' },
|
||||
{ id: 40, name: 'Tomato — Cherokee Purple' },
|
||||
])
|
||||
expect(m.get(6)).toBe('T')
|
||||
expect(m.get(40)).toBe('To')
|
||||
})
|
||||
|
||||
it('marks an unletterable name with a placeholder', () => {
|
||||
expect(monogramMap([{ id: 1, name: '123' }]).get(1)).toBe('?')
|
||||
expect(monogramFor('🌱')).toBe('?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// Plant markers are a solid circle in the plant's color with a 1–2 letter
|
||||
// monogram — the design's replacement for emoji icons. The letters are derived
|
||||
// from the name, never stored, so a renamed plant re-letters itself.
|
||||
//
|
||||
// Rule: the initial of the species (the part before a " — Variety" suffix).
|
||||
// Within one catalog, plants that share an initial are told apart by a second,
|
||||
// lowercase letter — Marigold → Ma, Mint → Mi. The bare initial goes to the
|
||||
// plant that entered the catalog first (lowest id): the built-ins are seeded
|
||||
// roughly commonest-first, so Tomato keeps T over Thyme, and a variety you add
|
||||
// later ("Tomato — Cherokee Purple") gets To rather than displacing it. The
|
||||
// collision set is the whole catalog, so a plant's letters are the same on
|
||||
// every screen.
|
||||
|
||||
/** "Tomato — Cherokee Purple" → "Tomato"; "Melon (Hale's Best)" → "Melon". */
|
||||
export function speciesName(name: string): string {
|
||||
const cut = name.split(/\s[—–-]\s|\s\(/)[0] ?? name
|
||||
return cut.trim() || name.trim()
|
||||
}
|
||||
|
||||
function letters(name: string): string[] {
|
||||
// Letters only; a leading digit or punctuation would make an unreadable mark.
|
||||
return speciesName(name)
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^A-Za-z]/g, '')
|
||||
.split('')
|
||||
}
|
||||
|
||||
/** The monogram for every plant in the catalog, keyed by id. */
|
||||
export function monogramMap<P extends { id: number; name: string }>(plants: readonly P[]): Map<number, string> {
|
||||
const out = new Map<number, string>()
|
||||
const taken = new Set<string>()
|
||||
const sorted = [...plants].sort((a, b) => a.id - b.id || a.name.localeCompare(b.name))
|
||||
for (const p of sorted) {
|
||||
const ls = letters(p.name)
|
||||
if (ls.length === 0) {
|
||||
out.set(p.id, '?')
|
||||
continue
|
||||
}
|
||||
const initial = ls[0].toUpperCase()
|
||||
// Prefer the bare initial, then initial + each following letter in turn —
|
||||
// skipping a repeat of the initial ("Pp" is not a mark anyone can read) —
|
||||
// then give up on uniqueness rather than grow past two letters.
|
||||
const candidates = [
|
||||
initial,
|
||||
...ls
|
||||
.slice(1)
|
||||
.filter((l) => l.toUpperCase() !== initial)
|
||||
.map((l) => initial + l.toLowerCase()),
|
||||
]
|
||||
const pick = candidates.find((c) => !taken.has(c)) ?? candidates[candidates.length - 1]
|
||||
taken.add(pick)
|
||||
out.set(p.id, pick)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A single plant's monogram when the catalog isn't at hand (a fallback, not the
|
||||
* collision-aware mark — prefer monogramMap where a list exists). */
|
||||
export function monogramFor(name: string): string {
|
||||
const ls = letters(name)
|
||||
return ls.length ? ls[0].toUpperCase() : '?'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
|
||||
|
||||
describe('plan names', () => {
|
||||
it('round-trips the default copy name', () => {
|
||||
expect(planNameFor('Home Garden', 2027)).toBe('Home Garden — 2027')
|
||||
expect(parsePlanName('Home Garden — 2027')).toEqual({ base: 'Home Garden', year: 2027 })
|
||||
})
|
||||
|
||||
it('accepts a plain hyphen or en dash with spaces around it', () => {
|
||||
expect(parsePlanName('Back forty - 2028')).toEqual({ base: 'Back forty', year: 2028 })
|
||||
expect(parsePlanName('Back forty – 2028')).toEqual({ base: 'Back forty', year: 2028 })
|
||||
})
|
||||
|
||||
it('does not read a bare year or an unspaced dash as a plan', () => {
|
||||
expect(parsePlanName('2027')).toBeNull()
|
||||
expect(parsePlanName('Plot-2027')).toBeNull()
|
||||
expect(parsePlanName('Home Garden')).toBeNull()
|
||||
})
|
||||
|
||||
it('tags only this year and later as plans', () => {
|
||||
const now = new Date(2026, 7, 22)
|
||||
expect(planYearOf('Home Garden — 2027', now)).toBe(2027)
|
||||
expect(planYearOf('Home Garden — 2026', now)).toBe(2026)
|
||||
expect(planYearOf('Home Garden — 2024', now)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds a garden’s plan copies in year order', () => {
|
||||
const gardens = [
|
||||
{ id: 1, name: 'Home Garden' },
|
||||
{ id: 2, name: 'Home Garden — 2028' },
|
||||
{ id: 3, name: 'Home Garden — 2027' },
|
||||
{ id: 4, name: 'Balcony — 2027' },
|
||||
]
|
||||
expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
// Season plans are whole-garden copies (POST /gardens/:id/copy): a separate
|
||||
// garden you rearrange freely while this year's stays put. The API keeps no link
|
||||
// between a copy and its source, so the relationship rides on the name the copy
|
||||
// is given by default — "Home Garden — 2027" — which is what the editor's season
|
||||
// control and the gardens list read back. Rename the copy and it is simply a
|
||||
// garden again; nothing breaks, it just stops being offered as a plan.
|
||||
|
||||
const PLAN_RE = /^(.+?)\s+[—–-]\s+(\d{4})$/
|
||||
|
||||
export interface PlanName {
|
||||
base: string
|
||||
year: number
|
||||
}
|
||||
|
||||
/** "Home Garden — 2027" → { base: "Home Garden", year: 2027 }, else null. */
|
||||
export function parsePlanName(name: string): PlanName | null {
|
||||
const m = name.trim().match(PLAN_RE)
|
||||
if (!m) return null
|
||||
return { base: m[1].trim(), year: Number(m[2]) }
|
||||
}
|
||||
|
||||
/** The default name for a plan copy of `base` for `year`. */
|
||||
export function planNameFor(base: string, year: number): string {
|
||||
return `${base} — ${year}`
|
||||
}
|
||||
|
||||
/** Whether a garden reads as a forward-looking plan (a year-suffixed copy for
|
||||
* this year or later). Past years are archives, not plans, and get no tag. */
|
||||
export function planYearOf(name: string, now = new Date()): number | null {
|
||||
const p = parsePlanName(name)
|
||||
return p && p.year >= now.getFullYear() ? p.year : null
|
||||
}
|
||||
|
||||
/** The plan copies of `base` among `gardens`, ascending by year. */
|
||||
export function planGardensOf<G extends { id: number; name: string }>(base: string, gardens: readonly G[]): (G & { year: number })[] {
|
||||
const out: (G & { year: number })[] = []
|
||||
for (const g of gardens) {
|
||||
const p = parsePlanName(g.name)
|
||||
if (p && p.base === base.trim()) out.push({ ...g, year: p.year })
|
||||
}
|
||||
return out.sort((a, b) => a.year - b.year)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// The Seed Tray: a small, deliberately-curated set of plants a gardener keeps at
|
||||
// hand while planting a bed, so repeat placement is a single tap instead of a
|
||||
// trip through the full catalog. It's a per-garden convenience, persisted in
|
||||
// localStorage (same rationale as the PlantPicker's "recent plants"): a nicety,
|
||||
// not authoritative state, so a quota/availability failure is swallowed.
|
||||
//
|
||||
// Only plant ids are stored; they resolve against the live catalog on read, so a
|
||||
// renamed plant stays current and a deleted one silently drops out of the tray.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { usePlants, type Plant } from './plants'
|
||||
|
||||
const KEY = (gardenId: number) => `pansy:seed-tray:${gardenId}`
|
||||
const TRAY_MAX = 24 // a working set, not a catalog; caps pathological growth
|
||||
|
||||
function loadIds(gardenId: number): number[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]')
|
||||
const ids = Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
||||
return ids.slice(-TRAY_MAX) // honor the cap even if storage was hand-edited
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveIds(gardenId: number, ids: number[]) {
|
||||
try {
|
||||
localStorage.setItem(KEY(gardenId), JSON.stringify(ids))
|
||||
} catch {
|
||||
// The tray is a convenience; ignore quota/availability failures.
|
||||
}
|
||||
}
|
||||
|
||||
export interface SeedTrayState {
|
||||
/** Tray plants in insertion order, resolved against the live catalog. */
|
||||
trayPlants: Plant[]
|
||||
/** Add a plant to the end of the tray (no-op if already present; when the tray
|
||||
* is full the oldest entry is evicted). */
|
||||
add: (plant: Plant) => void
|
||||
/** Remove a plant from the tray. */
|
||||
remove: (id: number) => void
|
||||
}
|
||||
|
||||
/** Per-garden Seed Tray backed by localStorage and the plant catalog. */
|
||||
export function useSeedTray(gardenId: number): SeedTrayState {
|
||||
const plants = usePlants()
|
||||
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
||||
|
||||
// Re-read when switching gardens (the route param changes without remounting).
|
||||
useEffect(() => {
|
||||
setIds(loadIds(gardenId))
|
||||
}, [gardenId])
|
||||
|
||||
const byId = useMemo(() => new Map((plants.data ?? []).map((p) => [p.id, p])), [plants.data])
|
||||
|
||||
// Resolve ids → plants, dropping any no longer in the catalog; preserve order.
|
||||
const trayPlants = useMemo(
|
||||
() => ids.map((id) => byId.get(id)).filter((p): p is Plant => !!p),
|
||||
[ids, byId],
|
||||
)
|
||||
|
||||
const add = useCallback(
|
||||
(plant: Plant) =>
|
||||
setIds((prev) => {
|
||||
if (prev.includes(plant.id)) return prev
|
||||
const next = [...prev, plant.id].slice(-TRAY_MAX)
|
||||
saveIds(gardenId, next)
|
||||
return next
|
||||
}),
|
||||
[gardenId],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
(id: number) =>
|
||||
setIds((prev) => {
|
||||
const next = prev.filter((x) => x !== id)
|
||||
saveIds(gardenId, next)
|
||||
return next
|
||||
}),
|
||||
[gardenId],
|
||||
)
|
||||
|
||||
return { trayPlants, add, remove }
|
||||
}
|
||||
+25
-9
@@ -1,9 +1,10 @@
|
||||
// Instance settings data layer (#79): admin-only, instance-wide configuration.
|
||||
//
|
||||
// The GET/PATCH return both the stored settings and a read-only "effective" view
|
||||
// — what's actually in force after layering the DB over the environment — so the
|
||||
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
|
||||
// whether the API key is present, without the key ever crossing the wire.
|
||||
// The GET/PATCH return the stored settings, a read-only "effective" view — what's
|
||||
// actually in force after layering the DB over the environment — and a read-only
|
||||
// view of the sign-in configuration, so the Settings page can say "inheriting
|
||||
// ollama-cloud/glm-5.2:cloud from the environment", whether the API key is
|
||||
// present (never its value), and who gets in.
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
@@ -15,6 +16,8 @@ export const instanceSettingsSchema = z.object({
|
||||
agentModel: z.string(),
|
||||
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
|
||||
agentEnabled: z.boolean().nullable(),
|
||||
// '' means "inherit PANSY_VISION_MODEL" (and with nothing there, no scanning).
|
||||
visionModel: z.string().default(''),
|
||||
version: z.number(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -25,12 +28,26 @@ export const effectiveAgentSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
hasApiKey: z.boolean(),
|
||||
agentLive: z.boolean(),
|
||||
visionModel: z.string().default(''),
|
||||
visionReady: z.boolean().default(false),
|
||||
})
|
||||
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
|
||||
|
||||
// Environment-driven sign-in config, reported read-only so the "Who gets in"
|
||||
// card shows what's in force. Defaults keep an older server's response parsing.
|
||||
export const authViewSchema = z.object({
|
||||
registration: z.enum(['open', 'closed']).catch('open'),
|
||||
localAuth: z.boolean().default(true),
|
||||
oidc: z.boolean().default(false),
|
||||
oidcIssuer: z.string().default(''),
|
||||
oidcLabel: z.string().default(''),
|
||||
})
|
||||
export type AuthView = z.infer<typeof authViewSchema>
|
||||
|
||||
export const settingsResponseSchema = z.object({
|
||||
settings: instanceSettingsSchema,
|
||||
effective: effectiveAgentSchema,
|
||||
auth: authViewSchema.default({ registration: 'open', localAuth: true, oidc: false, oidcIssuer: '', oidcLabel: '' }),
|
||||
})
|
||||
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
|
||||
|
||||
@@ -38,8 +55,7 @@ export const settingsKey = ['settings'] as const
|
||||
|
||||
export const settingsQueryOptions = queryOptions({
|
||||
queryKey: settingsKey,
|
||||
queryFn: async (): Promise<SettingsResponse> =>
|
||||
settingsResponseSchema.parse(await api.get('/settings')),
|
||||
queryFn: async (): Promise<SettingsResponse> => settingsResponseSchema.parse(await api.get('/settings')),
|
||||
})
|
||||
|
||||
export function useSettings() {
|
||||
@@ -49,6 +65,7 @@ export function useSettings() {
|
||||
export interface SettingsUpdate {
|
||||
agentModel: string
|
||||
agentEnabled: boolean | null
|
||||
visionModel: string
|
||||
version: number
|
||||
}
|
||||
|
||||
@@ -59,9 +76,8 @@ export function useUpdateSettings() {
|
||||
settingsResponseSchema.parse(await api.patch('/settings', input)),
|
||||
onSuccess: (res) => {
|
||||
qc.setQueryData(settingsKey, res)
|
||||
// The save may have turned the assistant on or off; the editor keys its
|
||||
// chat tab off /capabilities, so make it re-read rather than trust its
|
||||
// cached answer.
|
||||
// The save may have turned the assistant or scanning on or off; the editor
|
||||
// keys those off /capabilities, so make it re-read.
|
||||
qc.invalidateQueries({ queryKey: capabilitiesKey })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// The typed face of the theme mechanism that index.html installs as
|
||||
// window.PansyTheme before the first paint. The dark token values and the
|
||||
// localStorage key live THERE, not here: one source, applied before React loads
|
||||
// (so no cream flash for a dark-mode user). This module only keeps the chosen
|
||||
// preference in a tiny store so the nav's theme button and Settings → Appearance
|
||||
// render the same value, and re-applies the system theme when the OS flips.
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
export type ThemePref = 'system' | 'light' | 'dark'
|
||||
export const THEME_PREFS: ThemePref[] = ['system', 'light', 'dark']
|
||||
|
||||
interface PansyThemeApi {
|
||||
get(): string
|
||||
set(pref: ThemePref): void
|
||||
sysDark(): boolean
|
||||
isDark(pref: ThemePref): boolean
|
||||
apply(dark: boolean): void
|
||||
watch(cb: () => void): () => void
|
||||
next(pref: ThemePref): ThemePref
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
PansyTheme?: PansyThemeApi
|
||||
}
|
||||
}
|
||||
|
||||
// Without the bootstrap (tests, a stripped index.html) the app still runs: the
|
||||
// preference is remembered for the session and nothing is applied.
|
||||
const fallback: PansyThemeApi = {
|
||||
get: () => 'system',
|
||||
set: () => {},
|
||||
sysDark: () => false,
|
||||
isDark: (p) => p === 'dark',
|
||||
apply: () => {},
|
||||
watch: () => () => {},
|
||||
next: (p) => (p === 'system' ? 'light' : p === 'light' ? 'dark' : 'system'),
|
||||
}
|
||||
|
||||
function api(): PansyThemeApi {
|
||||
return (typeof window !== 'undefined' && window.PansyTheme) || fallback
|
||||
}
|
||||
|
||||
function isPref(v: string): v is ThemePref {
|
||||
return v === 'system' || v === 'light' || v === 'dark'
|
||||
}
|
||||
|
||||
let pref: ThemePref = (() => {
|
||||
const v = api().get()
|
||||
return isPref(v) ? v : 'system'
|
||||
})()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((l) => l())
|
||||
}
|
||||
|
||||
// Follow the OS while the preference is "system". Installed once per load.
|
||||
if (typeof window !== 'undefined') {
|
||||
api().watch(() => {
|
||||
if (pref === 'system') api().apply(api().isDark('system'))
|
||||
emit()
|
||||
})
|
||||
}
|
||||
|
||||
export function getThemePref(): ThemePref {
|
||||
return pref
|
||||
}
|
||||
|
||||
export function setThemePref(next: ThemePref): void {
|
||||
pref = next
|
||||
api().set(next)
|
||||
api().apply(api().isDark(next))
|
||||
emit()
|
||||
}
|
||||
|
||||
/** system → light → dark → system, as the nav's one-button toggle cycles. */
|
||||
export function cycleThemePref(): void {
|
||||
setThemePref(api().next(pref))
|
||||
}
|
||||
|
||||
/** Whether the page is currently dark (the pref resolved against the OS). */
|
||||
export function isDarkNow(): boolean {
|
||||
return api().isDark(pref)
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb)
|
||||
return () => {
|
||||
listeners.delete(cb)
|
||||
}
|
||||
}
|
||||
|
||||
export function useThemePref(): ThemePref {
|
||||
return useSyncExternalStore(subscribe, getThemePref, () => 'system')
|
||||
}
|
||||
|
||||
export const THEME_LABELS: Record<ThemePref, string> = {
|
||||
system: 'System',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
formatCm,
|
||||
formatDimensionInput,
|
||||
formatDimensions,
|
||||
formatFtIn,
|
||||
formatLength,
|
||||
formatSize,
|
||||
formatSpacing,
|
||||
parseDimension,
|
||||
spacingFromCm,
|
||||
@@ -209,3 +212,33 @@ describe('dimensionInputMode', () => {
|
||||
expect(dimensionInputMode('metric')).toBe('decimal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFtIn', () => {
|
||||
it('drops zero parts the way a gardener says it', () => {
|
||||
expect(formatFtIn(91)).toBe('3′')
|
||||
expect(formatFtIn(183)).toBe('6′')
|
||||
expect(formatFtIn(40)).toBe('1′4″')
|
||||
expect(formatFtIn(20)).toBe('8″')
|
||||
expect(formatFtIn(200)).toBe('6′7″')
|
||||
expect(formatFtIn(300)).toBe('9′10″')
|
||||
})
|
||||
|
||||
it('rounds to the nearest inch and carries 12″ up', () => {
|
||||
expect(formatFtIn(30.48)).toBe('1′')
|
||||
expect(formatFtIn(29.5)).toBe('1′') // 11.6″ → 12″ → 1′
|
||||
expect(formatFtIn(0)).toBe('0″')
|
||||
})
|
||||
|
||||
it('signs only a value that rounds to something', () => {
|
||||
expect(formatFtIn(-91)).toBe('-3′')
|
||||
expect(formatFtIn(-0.1)).toBe('0″')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSize / formatLength', () => {
|
||||
it('reads feet compactly and meters as before', () => {
|
||||
expect(formatSize(91, 183, 'imperial')).toBe('3′ × 6′')
|
||||
expect(formatSize(91, 183, 'metric')).toBe('0.91 m × 1.83 m')
|
||||
expect(formatLength(1219, 'imperial')).toBe('40′')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,3 +202,28 @@ export function spacingFromCm(cm: number, unit: UnitPref): number {
|
||||
export function spacingUnitLabel(unit: UnitPref): string {
|
||||
return unit === 'imperial' ? 'in' : 'cm'
|
||||
}
|
||||
|
||||
// --- Compact imperial display ----------------------------------------------
|
||||
// The editor and the cards label sizes the way a gardener says them — 3′, 1′4″,
|
||||
// 8″ — rather than the always-both "4′ 0″" the entry fields round-trip through.
|
||||
// Display only; nothing here is parsed back.
|
||||
|
||||
/** Centimeters → nearest inch, shown as feet/inches with zero parts dropped:
|
||||
* 91 → "3′", 40 → "1′4″", 20 → "8″". */
|
||||
export function formatFtIn(cm: number): string {
|
||||
const inches = Math.round(Math.abs(cm) / CM_PER_INCH)
|
||||
const feet = Math.floor(inches / INCHES_PER_FOOT)
|
||||
const rest = inches - feet * INCHES_PER_FOOT
|
||||
const s = feet ? (rest ? `${feet}′${rest}″` : `${feet}′`) : `${rest}″`
|
||||
return cm < 0 && inches ? `-${s}` : s
|
||||
}
|
||||
|
||||
/** One length in the garden's unit: compact feet/inches, or meters. */
|
||||
export function formatLength(cm: number, unit: UnitPref): string {
|
||||
return unit === 'imperial' ? formatFtIn(cm) : formatCm(cm, 'metric')
|
||||
}
|
||||
|
||||
/** "3′ × 6′" / "0.91 m × 1.83 m". */
|
||||
export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string {
|
||||
return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}`
|
||||
}
|
||||
|
||||
+578
-917
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
import { useState } from 'react'
|
||||
import { CopyDialog } from '@/components/gardens/CopyDialog'
|
||||
import { GardenCard } from '@/components/gardens/GardenCard'
|
||||
import { GardenDialog } from '@/components/gardens/GardenDialog'
|
||||
import { ShareDialog } from '@/components/gardens/ShareDialog'
|
||||
import { Nav } from '@/components/layout/Nav'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { CopyGardenModal } from '@/components/gardens/CopyGardenModal'
|
||||
import { DeleteGardenModal } from '@/components/gardens/DeleteGardenModal'
|
||||
import { GardenCard } from '@/components/gardens/GardenCard'
|
||||
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
|
||||
import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
|
||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { useGardens, type Garden } from '@/lib/gardens'
|
||||
import { useDeleteGarden, useGardens, type Garden } from '@/lib/gardens'
|
||||
import { useRemoveShare } from '@/lib/shares'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
// Which modal is open, if any. edit/delete/share/copy/leave carry the target garden.
|
||||
type Dialog =
|
||||
type DialogState =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'edit'; garden: Garden }
|
||||
| { kind: 'delete'; garden: Garden }
|
||||
@@ -25,33 +25,36 @@ export function GardensPage() {
|
||||
usePageTitle('Gardens')
|
||||
const gardens = useGardens()
|
||||
const me = useMe()
|
||||
const [dialog, setDialog] = useState<Dialog>(null)
|
||||
const [dialog, setDialog] = useState<DialogState>(null)
|
||||
const close = () => setDialog(null)
|
||||
const hasGardens = !!gardens.data && gardens.data.length > 0
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Gardens</h1>
|
||||
{hasGardens && <Button onClick={() => setDialog({ kind: 'create' })}>New garden</Button>}
|
||||
</div>
|
||||
<div className="min-h-full">
|
||||
<Nav active="gardens" />
|
||||
<div className="mx-auto max-w-[1080px] px-6 pb-14 pt-7">
|
||||
<div className="mb-5 flex flex-wrap items-baseline gap-3.5">
|
||||
<h2>Gardens</h2>
|
||||
<span className="text-sm font-semibold text-ink-mute">Every plot you tend — and the ones you're scheming</span>
|
||||
<Button variant="primary" icon="plus" className="ml-auto" onClick={() => setDialog({ kind: 'create' })}>
|
||||
New garden
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{gardens.isPending && <p className="text-sm text-muted">Loading your gardens…</p>}
|
||||
{gardens.isPending && <p className="text-[13px] text-ink-mute">Loading your gardens…</p>}
|
||||
{gardens.isError && <Alert>Could not load your gardens. Please refresh.</Alert>}
|
||||
|
||||
{gardens.isSuccess && gardens.data.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed border-border p-10 text-center">
|
||||
<p className="text-fg">No gardens yet.</p>
|
||||
<p className="mt-1 text-sm text-muted">Create your first garden to start planning.</p>
|
||||
<Button className="mt-4" onClick={() => setDialog({ kind: 'create' })}>
|
||||
Create your first garden
|
||||
<div className="panel flex flex-col items-center gap-2 border-dashed px-6 py-12 text-center">
|
||||
<h4>No gardens yet</h4>
|
||||
<p className="text-[13px] text-ink-soft">Break ground on the first one — it only needs a name and a size.</p>
|
||||
<Button variant="primary" icon="plus" className="mt-2" onClick={() => setDialog({ kind: 'create' })}>
|
||||
New garden
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gardens.isSuccess && gardens.data.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-[18px] [grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr))]">
|
||||
{gardens.data.map((g) => (
|
||||
<GardenCard
|
||||
key={g.id}
|
||||
@@ -68,12 +71,51 @@ export function GardensPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dialog?.kind === 'create' && <GardenFormModal onClose={close} />}
|
||||
{dialog?.kind === 'edit' && <GardenFormModal garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'delete' && <DeleteGardenModal garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'share' && <ShareGardenModal garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'copy' && <CopyGardenModal garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'leave' && <LeaveGardenModal garden={dialog.garden} onClose={close} />}
|
||||
</section>
|
||||
{dialog?.kind === 'create' && <GardenDialog onClose={close} />}
|
||||
{dialog?.kind === 'edit' && <GardenDialog garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'share' && <ShareDialog garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'copy' && <CopyDialog garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'delete' && <DeleteGardenDialog garden={dialog.garden} onClose={close} />}
|
||||
{dialog?.kind === 'leave' && <LeaveGardenDialog garden={dialog.garden} onClose={close} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteGardenDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const deletion = useDeleteGarden()
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Delete this garden?"
|
||||
confirmLabel="Delete it"
|
||||
busyLabel="Deleting…"
|
||||
errorFallback="Could not delete the garden."
|
||||
onConfirm={() => deletion.mutateAsync(garden.id)}
|
||||
onClose={onClose}
|
||||
>
|
||||
<span className="font-semibold text-text">{garden.name}</span> and everything planned in it — beds, plantings, the
|
||||
journal, its history. Deleting a whole garden is the one thing that can't be undone.
|
||||
</ConfirmDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function LeaveGardenDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const me = useMe()
|
||||
const remove = useRemoveShare(garden.id)
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Leave this garden?"
|
||||
confirmLabel="Leave"
|
||||
busyLabel="Leaving…"
|
||||
confirmDisabled={!me.data}
|
||||
errorFallback="Could not leave the garden."
|
||||
onConfirm={async () => {
|
||||
if (!me.data) throw new Error('Not signed in.')
|
||||
await remove.mutateAsync(me.data.id)
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
You'll lose access to <span className="font-semibold text-text">{garden.name}</span> until the owner shares it with you
|
||||
again.
|
||||
</ConfirmDialog>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user