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