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,9 +1,16 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, getRouteApi } from '@tanstack/react-router'
|
||||
import { Brand } from '@/components/layout/Nav'
|
||||
import { ThemeButton } from '@/components/layout/ThemeButton'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||
import { IconButton } from '@/components/ui/Button'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import { Canvas, type CanvasHandle } from '@/editor/Canvas'
|
||||
import { PHONE_BREAKPOINT } from '@/editor/shared'
|
||||
import { useEditorStore } from '@/editor/store'
|
||||
import type { EditorGarden } from '@/editor/types'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { monogramMap } from '@/lib/monogram'
|
||||
import { toEditorObject } from '@/lib/objects'
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
import { usePublicGarden } from '@/lib/publicGarden'
|
||||
@@ -12,62 +19,73 @@ import { usePageTitle } from '@/lib/usePageTitle'
|
||||
const routeApi = getRouteApi('/g/$token')
|
||||
|
||||
/**
|
||||
* The public, read-only garden view behind a share token. Rendered on the
|
||||
* unauthenticated `/g/$token` route (no auth guard), so a logged-out visitor
|
||||
* never hits /login or OIDC. Reuses GardenCanvas with canEdit=false — pan/zoom
|
||||
* to explore, but no editing chrome.
|
||||
* The public, read-only garden behind a share token — the unauthenticated
|
||||
* `/g/$token` route (no auth guard), so a logged-out visitor never hits /login
|
||||
* or OIDC. The same canvas, with every mutation entry point off.
|
||||
*/
|
||||
export function PublicGardenPage() {
|
||||
const { token } = routeApi.useParams()
|
||||
const full = usePublicGarden(token)
|
||||
usePageTitle(full.data?.garden.name ?? 'Shared garden')
|
||||
const canvasRef = useRef<CanvasHandle>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [vw, setVw] = useState(() => (typeof window !== 'undefined' ? window.innerWidth : 1024))
|
||||
|
||||
// Start from a clean canvas: if a logged-in user reaches here from their own
|
||||
// editor, stale focus/selection state would otherwise dim or highlight things.
|
||||
useEffect(() => {
|
||||
useEditorStore.getState().resetTransient()
|
||||
useEditorStore.getState().reset()
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => setVw(entry.contentRect.width))
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
const isMobile = vw < PHONE_BREAKPOINT
|
||||
|
||||
const objects = useMemo(() => full.data?.objects.map(toEditorObject) ?? [], [full.data?.objects])
|
||||
const plantings = useMemo(() => full.data?.plantings.map(toEditorPlanting) ?? [], [full.data?.plantings])
|
||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
||||
const letters = useMemo(() => monogramMap(plants), [plants])
|
||||
|
||||
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
||||
if (full.isError)
|
||||
if (full.isPending) return <p className="p-7 text-[13px] font-semibold text-ink-mute">Loading the garden…</p>
|
||||
if (full.isError) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Alert>This shared link isn’t available. The owner may have turned it off or changed it.</Alert>
|
||||
<div className="flex min-h-dvh flex-col bg-bg">
|
||||
<div className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<Link to="/" className="no-underline">
|
||||
<Brand />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-7">
|
||||
<Alert>This shared link isn't available. The owner may have turned it off or changed it.</Alert>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const g = full.data.garden
|
||||
const garden: EditorGarden = {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
widthCm: g.widthCm,
|
||||
heightCm: g.heightCm,
|
||||
unitPref: g.unitPref,
|
||||
gridSizeCm: g.gridSizeCm,
|
||||
snapToGrid: g.snapToGrid,
|
||||
}
|
||||
|
||||
const g = full.data.garden
|
||||
const garden: EditorGarden = { id: g.id, name: g.name, widthCm: g.widthCm, heightCm: g.heightCm, unitPref: g.unitPref, gridSizeCm: g.gridSizeCm, snapToGrid: g.snapToGrid }
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
</h1>
|
||||
<span className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 Shared · read-only</span>
|
||||
<div ref={rootRef} className="flex h-dvh flex-col bg-bg">
|
||||
<div className="flex flex-none items-center gap-3 px-3.5 py-2.5">
|
||||
<Link to="/" className="no-underline">
|
||||
<Brand />
|
||||
</Link>
|
||||
<span className="min-w-0 truncate font-heading text-[17px]" title={g.name}>
|
||||
{g.name}
|
||||
</span>
|
||||
<Tag tone="accent-2" className="flex-none">
|
||||
shared · read-only
|
||||
</Tag>
|
||||
<ThemeButton className="ml-auto" />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<GardenCanvas
|
||||
garden={garden}
|
||||
objects={objects}
|
||||
plantings={plantings}
|
||||
plantsById={plantsById}
|
||||
canEdit={false}
|
||||
/>
|
||||
<div className={cn('relative min-h-0 flex-1', !isMobile && 'panel m-3.5 mt-0 overflow-hidden')}>
|
||||
<Canvas ref={canvasRef} garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} letters={letters} canEdit={false} isMobile={isMobile} />
|
||||
<IconButton label="Fit the garden" icon="maximize" iconSize={16} variant="plain" size={40} className="elev-sm absolute bottom-3 right-3 border border-divider bg-surface" onClick={() => canvasRef.current?.zoomFit()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user