Home mosaic, multi-select delete, offline banner and loading states

- Home puts the dashboards beside the flows: two equal-height columns,
  capped and scrollable, most recently worked on first. Each tile is a
  schematic footprint built from the stored widget placements.
- Flows and dashboards can be picked by long press or ctrl-click; the
  create button becomes a trash and one dialog covers the batch.
- The offline banner is drawn on the body so it centres on the viewport,
  and the live socket now releases the offline latch a stray 503 set.
- A boot spinner before React's first commit, a router pending screen for
  code-split pages, and skeletons where an empty list used to flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
2026-08-22 12:02:14 +02:00
co-authored by Claude Opus 5
parent 6d84316ce5
commit f17d51c12f
13 changed files with 837 additions and 89 deletions
@@ -1,10 +1,18 @@
import { useMutation } from "@tanstack/react-query"
import { Check, Loader2, Plus, Search } from "lucide-react"
import { Check, Loader2, Plus, Search, Trash2 } from "lucide-react"
import { motion } from "motion/react"
import { type ReactNode, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { DialogTrigger } from "@/components/ui/dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Tooltip,
@@ -13,6 +21,7 @@ import {
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
/** The icon buttons here and in the flow dock are the same touch target. */
const ICON = "size-11 text-muted-foreground md:size-8"
@@ -26,7 +35,9 @@ const ICON = "size-11 text-muted-foreground md:size-8"
* once it is empty and left alone, which keeps the row down to three targets.
*
* The create button is a `DialogTrigger`, so the page wrapping this in its own
* `Dialog` owns what asking for a name looks like.
* `Dialog` owns what asking for a name looks like. While anything in the list
* is selected it stands down for the trash instead: the row keeps its three
* targets, and the one primary action is whichever the list is currently for.
*/
export function OverviewToolbar({
search,
@@ -38,6 +49,8 @@ export function OverviewToolbar({
draftCount,
publishing,
onPublishAll,
selectedCount = 0,
onDeleteSelected,
children,
}: {
search: string
@@ -50,6 +63,9 @@ export function OverviewToolbar({
draftCount: number
publishing: boolean
onPublishAll: () => void
/** How many entries are picked; above zero the list is in selection mode. */
selectedCount?: number
onDeleteSelected?: () => void
/** Anything this particular overview adds, drawn ahead of the shared icons. */
children?: ReactNode
}) {
@@ -110,19 +126,34 @@ export function OverviewToolbar({
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
{selectedCount > 0 ? (
<Button
variant="ghost"
size="icon"
className={ICON}
aria-label={createLabel}
data-testid={createTestId}
className={cn(ICON, "text-destructive")}
aria-label={`Delete ${selectedCount} selected`}
data-testid="delete-selected"
onClick={onDeleteSelected}
>
<Plus />
<Trash2 />
</Button>
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={ICON}
aria-label={createLabel}
data-testid={createTestId}
>
<Plus />
</Button>
</DialogTrigger>
)}
</TooltipTrigger>
<TooltipContent>{createLabel}</TooltipContent>
<TooltipContent>
{selectedCount > 0 ? `Delete ${selectedCount} selected` : createLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -193,3 +224,98 @@ export function usePublishAll(
},
})
}
/**
* Delete the selected documents, and say how many actually went.
*
* Same one-request-each shape as `usePublishAll`: one that someone else has
* already removed, or that the server refuses, fails on its own rather than
* taking the rest of the batch with it. Nothing here clears the selection —
* the list coming back without those names is what does that.
*/
export function useDeleteSelected(
remove: (name: string) => Promise<unknown>,
noun: string,
onDone: () => void,
) {
const { showSuccessToast, showErrorToast } = useCustomToast()
return useMutation({
mutationFn: async (names: string[]) => {
const failed: string[] = []
for (const name of names) {
try {
await remove(name)
} catch {
failed.push(name)
}
}
return failed
},
// Some of them are gone even when others are not.
onSettled: onDone,
onSuccess: (failed, names) => {
if (failed.length)
showErrorToast(
`Deleted ${names.length - failed.length} of ${names.length}. Still there: ${failed.join(", ")}`,
)
else
showSuccessToast(
`Deleted ${names.length} ${noun}${names.length === 1 ? "" : "s"}`,
)
},
})
}
/**
* The one question a batch delete asks, however many were picked.
*
* Names the single one it is about, counts the rest: a list of twelve names in
* a dialog is read as decoration rather than as a check.
*/
export function ConfirmDelete({
open,
onOpenChange,
names,
noun,
pending,
onConfirm,
}: {
open: boolean
onOpenChange: (open: boolean) => void
names: string[]
noun: string
pending: boolean
onConfirm: () => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{names.length === 1
? `Delete "${names[0]}"?`
: `Delete these ${names.length} ${noun}s?`}
</DialogTitle>
<DialogDescription>
{names.length === 1 ? "It goes" : "They go"} from the installation
at once. The store's git history keeps what was there, but nothing
in the app brings {names.length === 1 ? "it" : "them"} back.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep {names.length === 1 ? "it" : "them"}
</Button>
<Button
variant="destructive"
disabled={pending}
onClick={onConfirm}
data-testid="confirm-delete"
>
Delete {names.length === 1 ? noun : `${names.length} ${noun}s`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}