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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user