Files
app/frontend/src/components/Common/OverviewCard.tsx
T
stroblmeandClaude Opus 5 f17d51c12f 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
2026-08-22 12:02:14 +02:00

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>
)
}