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