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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user