Build image / build-and-push (push) Successful in 10s
- monogramInk is memoized by color string; the canvas asks for every visible plop on every frame of a pan (Gadfly, 2/4 models). - FALLBACK_PLANT_COLOR lives in lib/plants and is used by the canvas, the inspector and the garden thumbnail instead of three raw '#97a97c's. - CopyDialog keeps its proposed "<base> — <year>" in step with the gardens list until the person edits the name, so a list that loads after the dialog opens can't leave a taken year in the field. - GardenCard: reflowed the summary comment; no dead fallback on a plan name that's already known to parse. - today() has one import path (lib/dates); the journal re-export is gone. - CLAUDE.md says what the inspector actually does (a text-compare guard) rather than claiming it uses LengthField. Co-Authored-By: Claude Fable 5 <[email protected]>
252 lines
9.3 KiB
TypeScript
252 lines
9.3 KiB
TypeScript
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 { today } from '@/lib/dates'
|
|
import {
|
|
formatObservedAt,
|
|
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>
|
|
)
|
|
}
|