Build image / build-and-push (push) Successful in 10s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
147 lines
5.3 KiB
TypeScript
147 lines
5.3 KiB
TypeScript
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 text-right"
|
|
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()
|
|
}
|