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