Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import { useState } from 'react'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { TextArea } from '@/components/ui/TextArea'
|
|
import { TextField } from '@/components/ui/TextField'
|
|
import { errorMessage } from '@/lib/api'
|
|
import {
|
|
formatObservedAt,
|
|
today,
|
|
useCreateJournalEntry,
|
|
useDeleteJournalEntry,
|
|
useJournal,
|
|
useUpdateJournalEntry,
|
|
type JournalEntry,
|
|
} from '@/lib/journal'
|
|
import type { EditorObject } from './types'
|
|
import { objectDisplayName } from './kinds'
|
|
|
|
/**
|
|
* The garden's journal: write an entry, read the season back.
|
|
*
|
|
* Notes get written standing in the garden holding a phone, usually one-handed.
|
|
* If it takes more than a couple of taps from looking at a bed to typing a
|
|
* sentence, the log stays empty — so the composer is open by default rather than
|
|
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
|
|
*/
|
|
export function JournalPanel({
|
|
gardenId,
|
|
canEdit,
|
|
currentUserId,
|
|
isOwner,
|
|
objects,
|
|
scopeObjectId,
|
|
onScopeChange,
|
|
}: {
|
|
gardenId: number
|
|
canEdit: boolean
|
|
currentUserId?: number
|
|
isOwner: boolean
|
|
objects: EditorObject[]
|
|
/** Which bed the panel is filtered to, if any. */
|
|
scopeObjectId: number | null
|
|
onScopeChange: (id: number | null) => void
|
|
}) {
|
|
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
|
|
const journal = useJournal(gardenId, filter)
|
|
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
|
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 className="text-sm font-semibold text-fg">Journal</h2>
|
|
{objects.length > 0 && (
|
|
<select
|
|
value={scopeObjectId ?? ''}
|
|
onChange={(e) => {
|
|
const id = Number(e.target.value)
|
|
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
|
|
}}
|
|
className="max-w-[9rem] truncate rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
<option value="">Whole garden</option>
|
|
{objects.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{objectDisplayName(o)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<Composer
|
|
gardenId={gardenId}
|
|
objectId={scopeObjectId}
|
|
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
|
|
/>
|
|
)}
|
|
|
|
{journal.isPending && <p className="text-sm text-muted">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-sm text-muted">
|
|
{scopedObject
|
|
? `Nothing written about ${objectDisplayName(scopedObject)} 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>
|
|
)}
|
|
|
|
<ol className="flex flex-col gap-2">
|
|
{entries.map((e) => (
|
|
<Entry
|
|
key={e.id}
|
|
entry={e}
|
|
gardenId={gardenId}
|
|
objects={objects}
|
|
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
|
|
canRewrite={canEdit && e.authorId === currentUserId}
|
|
/>
|
|
))}
|
|
</ol>
|
|
|
|
{journal.hasNextPage && (
|
|
<Button
|
|
variant="ghost"
|
|
className="text-sm"
|
|
disabled={journal.isFetchingNextPage}
|
|
onClick={() => void journal.fetchNextPage()}
|
|
>
|
|
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
|
</Button>
|
|
)}
|
|
{journal.isError && entries.length > 0 && (
|
|
<p className="text-xs text-red-700 dark:text-red-400">
|
|
{errorMessage(journal.error, "Couldn't load older entries.")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Composer({
|
|
gardenId,
|
|
objectId,
|
|
scopeLabel,
|
|
}: {
|
|
gardenId: number
|
|
objectId: number | null
|
|
scopeLabel: string | null
|
|
}) {
|
|
const create = useCreateJournalEntry(gardenId)
|
|
const [body, setBody] = useState('')
|
|
// Editable so an observation can be backdated: you write up Saturday on Sunday.
|
|
const [observedAt, setObservedAt] = useState(today())
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const submit = () => {
|
|
const text = body.trim()
|
|
if (!text) return
|
|
setError(null)
|
|
create.mutate(
|
|
{ body: text, observedAt, objectId: objectId ?? undefined },
|
|
{
|
|
onSuccess: () => {
|
|
setBody('')
|
|
setObservedAt(today())
|
|
},
|
|
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
|
|
<TextArea
|
|
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
|
|
name="journalBody"
|
|
rows={3}
|
|
placeholder="What happened?"
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
/>
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<TextField
|
|
label="Observed"
|
|
name="observedAt"
|
|
type="date"
|
|
value={observedAt}
|
|
onChange={(e) => setObservedAt(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
|
|
{create.isPending ? 'Saving…' : 'Save note'}
|
|
</Button>
|
|
</div>
|
|
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Entry({
|
|
entry,
|
|
gardenId,
|
|
objects,
|
|
canDelete,
|
|
canRewrite,
|
|
}: {
|
|
entry: JournalEntry
|
|
gardenId: number
|
|
objects: EditorObject[]
|
|
/** May remove it: the author, or the garden owner. */
|
|
canDelete: boolean
|
|
/** May rewrite the text: the author only — rewriting someone else's
|
|
* observation under their name is a different act from removing it. */
|
|
canRewrite: boolean
|
|
}) {
|
|
const update = useUpdateJournalEntry(gardenId)
|
|
const del = useDeleteJournalEntry(gardenId)
|
|
const [editing, setEditing] = useState(false)
|
|
const [draft, setDraft] = useState(entry.body)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const about = objects.find((o) => o.id === entry.objectId) ?? null
|
|
|
|
// Re-sync the draft when the entry changes underneath — a refetch after
|
|
// someone else's edit, or this entry's own successful save. Skipped while
|
|
// editing so a background refetch can't clobber what's being typed; the
|
|
// version guard is what catches a genuine collision.
|
|
const [syncedBody, setSyncedBody] = useState(entry.body)
|
|
if (!editing && entry.body !== syncedBody) {
|
|
setSyncedBody(entry.body)
|
|
setDraft(entry.body)
|
|
}
|
|
|
|
const save = () => {
|
|
const text = draft.trim()
|
|
if (!text) {
|
|
// Silence here reads as a broken button; say why nothing happened.
|
|
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
|
|
return
|
|
}
|
|
setError(null)
|
|
update.mutate(
|
|
{ id: entry.id, version: entry.version, body: text },
|
|
{
|
|
onSuccess: () => {
|
|
setEditing(false)
|
|
setError(null)
|
|
},
|
|
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
|
{editing ? (
|
|
<div className="flex flex-col gap-2">
|
|
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-1 text-xs"
|
|
onClick={() => {
|
|
setDraft(entry.body)
|
|
setEditing(false)
|
|
setError(null)
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
|
|
{update.isPending ? 'Saving…' : 'Save'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
|
|
)}
|
|
|
|
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
|
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
|
|
<span aria-hidden>·</span>
|
|
<span>{entry.authorName}</span>
|
|
{about && (
|
|
<>
|
|
<span aria-hidden>·</span>
|
|
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
|
|
</>
|
|
)}
|
|
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
|
|
</p>
|
|
|
|
{(canRewrite || canDelete) && !editing && (
|
|
<div className="mt-1 flex justify-end gap-1">
|
|
{canRewrite && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
{canDelete && (
|
|
<button
|
|
type="button"
|
|
disabled={del.isPending}
|
|
onClick={() => {
|
|
setError(null) // clear a previous failure so a retry isn't read as another
|
|
del.mutate(entry.id, {
|
|
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
|
|
})
|
|
}}
|
|
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
|
|
</li>
|
|
)
|
|
}
|