Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #63.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
/**
|
||||
* The editor's side rail, and the answer to "four things want one rail".
|
||||
*
|
||||
* The inspector, history, and (later) the journal and chat panel all want the
|
||||
* same strip of screen. Rather than each bolting on its own chrome, they are
|
||||
* tabs in one rail — which keeps the canvas one width instead of a different
|
||||
* width per panel, and means adding the journal or chat is adding a tab.
|
||||
*
|
||||
* Two constraints shaped it:
|
||||
*
|
||||
* - Selecting an object must land you in the inspector with no extra click.
|
||||
* The editor watches the selection and switches to that tab itself, so the
|
||||
* rail never becomes a thing you have to operate before you can edit.
|
||||
* - The canvas has to stay worth watching while the agent edits it, so the
|
||||
* rail is a fixed 20rem column and closes completely when nothing needs it.
|
||||
*
|
||||
* On a phone the same tabs render in a bottom sheet, which is where the
|
||||
* inspector already lived.
|
||||
*/
|
||||
|
||||
export interface RailTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Rendered lazily: only the active tab's content is built. */
|
||||
render: () => ReactNode
|
||||
/** Shown as a dot on the tab — a count, or true for a plain marker. */
|
||||
badge?: number | boolean
|
||||
}
|
||||
|
||||
export function EditorRail({
|
||||
tabs,
|
||||
activeId,
|
||||
onActivate,
|
||||
onClose,
|
||||
}: {
|
||||
tabs: RailTab[]
|
||||
activeId: string
|
||||
onActivate: (id: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: a bottom sheet over the canvas. Desktop: a column beside it.
|
||||
'fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
|
||||
'md:static md:max-h-none md:w-80 md:shrink-0 md:rounded-xl md:border md:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onActivate(tab.id)}
|
||||
aria-current={tab.id === active.id ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
tab.id === active.id ? 'bg-border/60 text-fg' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge != null && tab.badge !== false && tab.badge !== 0 && (
|
||||
<span className="rounded-full bg-accent/20 px-1.5 text-[10px] font-semibold text-accent-strong">
|
||||
{tab.badge === true ? '•' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="ml-auto rounded px-1.5 text-sm text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { describeCounts, totalChanges, useGardenHistory, useUndo, type ChangeSet } from '@/lib/history'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { UndoButton } from './UndoButton'
|
||||
|
||||
/**
|
||||
* The garden's change history, newest first, with an undo on each entry.
|
||||
*
|
||||
* A reverted entry stays in the list, marked — and the revert appears as its own
|
||||
* entry, because that is what it is. Making the original disappear would be
|
||||
* rewriting history rather than adding to it, and would leave no way to undo the
|
||||
* undo.
|
||||
*/
|
||||
export function HistoryPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||
const history = useGardenHistory(gardenId)
|
||||
const undo = useUndo(gardenId)
|
||||
const sets = history.data?.pages.flatMap((p) => p.changeSets) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-fg">History</h2>
|
||||
|
||||
{history.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||
{history.isError && sets.length === 0 && (
|
||||
<Alert>{errorMessage(history.error, 'Could not load history.')}</Alert>
|
||||
)}
|
||||
|
||||
{history.isSuccess && sets.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
Nothing yet. Every change you make here shows up in this list, and can be undone from it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{sets.map((cs) => (
|
||||
<HistoryEntry key={cs.id} changeSet={cs} canEdit={canEdit} undo={undo} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{history.hasNextPage && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-sm"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
{history.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
{/* isError stays true for a failed page even though earlier pages
|
||||
loaded, so say so rather than leaving a button that did nothing. */}
|
||||
{history.isError && sets.length > 0 && (
|
||||
<p className="text-xs text-red-700 dark:text-red-400">
|
||||
{errorMessage(history.error, "Couldn't load older entries.")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Said plainly rather than discovered: deleting a garden bypasses this
|
||||
list entirely, because the delete cascades below the layer that records
|
||||
changes. Better to state the gap than to imply cover we don't have. */}
|
||||
<p className="border-t border-border pt-2 text-xs text-muted">
|
||||
Deleting a whole garden isn't covered here and can't be undone.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryEntry({
|
||||
changeSet,
|
||||
canEdit,
|
||||
undo,
|
||||
}: {
|
||||
changeSet: ChangeSet
|
||||
canEdit: boolean
|
||||
undo: ReturnType<typeof useUndo>
|
||||
}) {
|
||||
const counts = describeCounts(changeSet)
|
||||
const reverted = changeSet.revertedById != null
|
||||
const isRevert = changeSet.revertsId != null
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'rounded-lg border border-border px-2.5 py-2 text-sm',
|
||||
reverted && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('text-fg', reverted && 'line-through decoration-muted')}>{changeSet.summary}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
{changeSet.source === 'agent' && (
|
||||
<span className="rounded bg-accent/20 px-1 py-px font-medium uppercase tracking-wide text-accent-strong">
|
||||
agent
|
||||
</span>
|
||||
)}
|
||||
{isRevert && <span className="rounded bg-border/60 px-1 py-px font-medium">undo</span>}
|
||||
<span>{changeSet.actorName}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<time dateTime={changeSet.createdAt}>{relativeTime(changeSet.createdAt)}</time>
|
||||
{counts && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{counts}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{reverted && <p className="mt-1 text-xs text-muted">Undone</p>}
|
||||
</div>
|
||||
|
||||
{canEdit && totalChanges(changeSet) > 0 && (
|
||||
<UndoButton
|
||||
changeSet={changeSet}
|
||||
undo={undo}
|
||||
className="w-32 shrink-0"
|
||||
label={reverted ? 'Undo again' : 'Undo'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact "3m ago" / "yesterday" for a UTC timestamp. */
|
||||
export function relativeTime(iso: string): string {
|
||||
const then = Date.parse(iso)
|
||||
if (Number.isNaN(then)) return iso
|
||||
const seconds = (Date.now() - then) / 1000
|
||||
// Clock skew between the server and this browser can put a just-written entry
|
||||
// slightly in the future; that's "just now", not a negative duration.
|
||||
if (seconds < 60) return 'just now'
|
||||
// Floor throughout: 18 hours ago is "18h ago", not "yesterday". Rounding up
|
||||
// into the next unit reads as a bigger gap than actually elapsed.
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days === 1) return 'yesterday'
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(then).toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { ChangeSet, UndoOutcome, useUndo } from '@/lib/history'
|
||||
|
||||
/**
|
||||
* Undo, plus what happened. Paired with useUndo so the history list and the
|
||||
* agent turn's inline undo (#57) behave identically — including the part that
|
||||
* actually matters, which is reporting a partial result honestly rather than as
|
||||
* a success or a failure.
|
||||
*/
|
||||
export function UndoButton({
|
||||
changeSet,
|
||||
undo,
|
||||
className,
|
||||
label = 'Undo',
|
||||
}: {
|
||||
changeSet: ChangeSet
|
||||
undo: ReturnType<typeof useUndo>
|
||||
className?: string
|
||||
label?: string
|
||||
}) {
|
||||
const outcome = undo.outcomeFor(changeSet.id)
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-1', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
disabled={outcome?.tone === 'pending'}
|
||||
onClick={() => undo.undo(changeSet)}
|
||||
>
|
||||
{outcome?.tone === 'pending' ? 'Undoing…' : label}
|
||||
</Button>
|
||||
{outcome && outcome.tone !== 'pending' && <OutcomeNote outcome={outcome} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
|
||||
ok: 'text-muted',
|
||||
partial: 'text-amber-700 dark:text-amber-400',
|
||||
error: 'text-red-700 dark:text-red-400',
|
||||
}
|
||||
|
||||
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
||||
if (outcome.tone === 'pending') return null
|
||||
return (
|
||||
<p role="status" className={cn('text-right text-xs', TONE_CLASS[outcome.tone])}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,12 @@ interface EditorState {
|
||||
focusedObjectId: number | null
|
||||
setFocusedObject: (id: number | null) => void
|
||||
|
||||
// Which rail tab is showing, or null when the rail is closed. Selecting an
|
||||
// object switches this to 'inspector' (see GardenEditorPage), so editing never
|
||||
// starts with a click on the rail itself.
|
||||
railTab: string | null
|
||||
setRailTab: (tab: string | null) => void
|
||||
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
@@ -70,6 +76,9 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
focusedObjectId: null,
|
||||
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
||||
|
||||
railTab: null,
|
||||
setRailTab: (tab) => set({ railTab: tab }),
|
||||
|
||||
armedPlant: null,
|
||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||
|
||||
@@ -94,5 +103,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
railTab: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user