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:
@@ -0,0 +1,158 @@
|
||||
import { Link, type LinkProps } from "@tanstack/react-router"
|
||||
import { Check, type LucideIcon } from "lucide-react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** How long a press has to last before it means "select this one". */
|
||||
const LONG_PRESS = 450
|
||||
|
||||
/**
|
||||
* Which of the listed documents are picked, and what turns picking on.
|
||||
*
|
||||
* Selection mode is simply "something is selected": a long press picks the
|
||||
* first one and clearing the last one puts the list back to normal. So there
|
||||
* is no mode to be stuck in, nothing to cancel, and no control on screen that
|
||||
* exists only to turn selecting on.
|
||||
*
|
||||
* @param names everything currently listed. A document that is deleted, or
|
||||
* filtered out by the search, cannot stay picked — deleting therefore clears
|
||||
* the selection on its own, once the list comes back without it.
|
||||
*/
|
||||
export function useSelection(names: string[]) {
|
||||
const [picked, setPicked] = useState<string[]>([])
|
||||
const selected = picked.filter((name) => names.includes(name))
|
||||
|
||||
// Escape is the way out for anyone who started this by accident.
|
||||
useEffect(() => {
|
||||
if (selected.length === 0) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setPicked([])
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [selected.length])
|
||||
|
||||
return {
|
||||
selected,
|
||||
selecting: selected.length > 0,
|
||||
toggle: (name: string) =>
|
||||
setPicked((current) =>
|
||||
current.includes(name)
|
||||
? current.filter((other) => other !== name)
|
||||
: [...current, name],
|
||||
),
|
||||
clear: () => setPicked([]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of the flows or dashboards overview.
|
||||
*
|
||||
* Stays a link in both modes rather than swapping element: while selecting it
|
||||
* carries `role="checkbox"` instead, which is what it now behaves as. Ctrl or
|
||||
* Cmd click is the same gesture for a mouse, and reaches the keyboard for
|
||||
* free — a focused link answers Ctrl+Enter with a click carrying the modifier.
|
||||
*/
|
||||
export function OverviewCard({
|
||||
link,
|
||||
icon: Icon,
|
||||
title,
|
||||
detail,
|
||||
draft,
|
||||
selecting,
|
||||
selected,
|
||||
onToggle,
|
||||
testId,
|
||||
}: {
|
||||
link: LinkProps
|
||||
icon: LucideIcon
|
||||
title: ReactNode
|
||||
detail: ReactNode
|
||||
/** Has unpublished changes. */
|
||||
draft?: boolean
|
||||
selecting: boolean
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
testId?: string
|
||||
}) {
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const held = useRef(false)
|
||||
|
||||
const stop = () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...link}
|
||||
data-testid={testId}
|
||||
data-selected={selected || undefined}
|
||||
role={selecting ? "checkbox" : undefined}
|
||||
aria-checked={selecting ? selected : undefined}
|
||||
className={cn(
|
||||
"relative grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors",
|
||||
// Both states have to stay tellable apart, so hover is the half tint.
|
||||
selected ? "border-primary bg-accent" : "hover:bg-accent/50",
|
||||
selecting && "select-none",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
// Right-click is the browser's own gesture; leave it alone.
|
||||
if (event.button !== 0) return
|
||||
held.current = false
|
||||
timer.current = setTimeout(() => {
|
||||
held.current = true
|
||||
onToggle()
|
||||
}, LONG_PRESS)
|
||||
}}
|
||||
onPointerUp={stop}
|
||||
onPointerLeave={stop}
|
||||
onPointerCancel={stop}
|
||||
onClick={(event) => {
|
||||
// The press already picked it; the click that ends the press must not
|
||||
// undo it, and must certainly not navigate.
|
||||
if (held.current) {
|
||||
held.current = false
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (selecting || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
// A touch hold raises this a moment after the press has been taken as
|
||||
// a selection; the link preview on top of it would be a second answer
|
||||
// to one gesture.
|
||||
if (held.current) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{title}</span>
|
||||
{draft ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
||||
<span className="sr-only">Unpublished changes</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-sm text-muted-foreground">{detail}</span>
|
||||
|
||||
{selecting ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute top-3 right-3 flex size-5 items-center justify-center rounded-sm border",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
{selected ? <Check className="size-3.5" /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user