- 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
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
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>
|
|
)
|
|
}
|