From f696ee23e5cc25dad8eb85cfc310d9852932e756 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Sun, 23 Aug 2026 10:34:42 -0400 Subject: [PATCH 1/7] feat(ui): add shadcn Empty and Kbd components to the kit --- apps/web/src/components/dev/UiKit.tsx | 42 +++++++++++ packages/ui/src/components/ui/empty.tsx | 94 +++++++++++++++++++++++++ packages/ui/src/components/ui/kbd.tsx | 26 +++++++ packages/ui/tsup.config.ts | 2 + 4 files changed, 164 insertions(+) create mode 100644 packages/ui/src/components/ui/empty.tsx create mode 100644 packages/ui/src/components/ui/kbd.tsx diff --git a/apps/web/src/components/dev/UiKit.tsx b/apps/web/src/components/dev/UiKit.tsx index 409beee0..6a07129d 100644 --- a/apps/web/src/components/dev/UiKit.tsx +++ b/apps/web/src/components/dev/UiKit.tsx @@ -65,6 +65,15 @@ import { TooltipProvider, TooltipTrigger, } from "@uploads/ui/components/ui/tooltip"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@uploads/ui/components/ui/empty"; +import { Kbd, KbdGroup } from "@uploads/ui/components/ui/kbd"; import { Combobox, ComboboxContent, @@ -231,6 +240,39 @@ export default function UiKit() { +
+ + + + No screenshots yet + + Capture one with uploads screenshot <url> and it shows up here. + + + + + + +
+ +
+
+ + Filter + + + K + + + + Search + / + +
+
+
diff --git a/packages/ui/src/components/ui/empty.tsx b/packages/ui/src/components/ui/empty.tsx new file mode 100644 index 00000000..bab3115f --- /dev/null +++ b/packages/ui/src/components/ui/empty.tsx @@ -0,0 +1,94 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/packages/ui/src/components/ui/kbd.tsx b/packages/ui/src/components/ui/kbd.tsx new file mode 100644 index 00000000..44df6539 --- /dev/null +++ b/packages/ui/src/components/ui/kbd.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/lib/utils"; + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index ab793b15..e1aa492b 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -19,8 +19,10 @@ export default defineConfig({ "components/ui/combobox": "src/components/ui/combobox.tsx", "components/ui/dialog": "src/components/ui/dialog.tsx", "components/ui/dropdown-menu": "src/components/ui/dropdown-menu.tsx", + "components/ui/empty": "src/components/ui/empty.tsx", "components/ui/input": "src/components/ui/input.tsx", "components/ui/input-group": "src/components/ui/input-group.tsx", + "components/ui/kbd": "src/components/ui/kbd.tsx", "components/ui/label": "src/components/ui/label.tsx", "components/ui/popover": "src/components/ui/popover.tsx", "components/ui/select": "src/components/ui/select.tsx", From 54addb5c96a62686372bdedcbde566feba5e1cfb Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Sun, 23 Aug 2026 10:48:21 -0400 Subject: [PATCH 2/7] feat(web): adopt Empty and Kbd in the files and screenshots islands --- apps/web/src/components/ScreenshotsByPath.tsx | 78 ++++++++++++++----- .../web/src/components/WorkspaceFileTable.tsx | 61 ++++++++++++++- apps/web/src/styles/account-content.css | 40 ---------- 3 files changed, 118 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/ScreenshotsByPath.tsx b/apps/web/src/components/ScreenshotsByPath.tsx index 178af93c..502a0e34 100644 --- a/apps/web/src/components/ScreenshotsByPath.tsx +++ b/apps/web/src/components/ScreenshotsByPath.tsx @@ -21,6 +21,8 @@ */ import { Callout, Input, Select } from "@uploads/ui"; import "@uploads/ui/styles.css"; +import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from "@uploads/ui/components/ui/empty"; +import { Kbd } from "@uploads/ui/components/ui/kbd"; import { useEffect, useLayoutEffect, @@ -90,15 +92,41 @@ function EmptyShotsCta({ title }: { title: string }) { } }; return ( -
-

{title}

-
- {EMPTY_CTA_CMD} - -
-
+ + + {title} + + +
+ + {EMPTY_CTA_CMD} + + +
+
+
+ ); +} + +/** + * Compact Empty for inline filter/drill "nothing found" messages — smaller + * than the CTA-style empty state above, since these sit inside already- + * scoped list/grid contexts rather than replacing the whole page. + */ +function InlineEmpty({ title }: { title: ReactNode }) { + return ( + + + {title} + + ); } @@ -429,6 +457,13 @@ function FilterBar({ // useful to offer until the user edits it back into a query. const suggestions = path ? [] : pathSuggestions(catalog, { project, q }); const open = suggestOpen && suggestions.length > 0; + // "Ctrl K" is the deterministic default so the server's render and the + // client's first render agree bit-for-bit (no `navigator` at SSR time) — + // corrected to "⌘K" after mount, once hydration has already reconciled. + const [modKbd, setModKbd] = useState("Ctrl K"); + useEffect(() => { + if (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) setModKbd("⌘K"); + }, []); const pick = (picked: string) => { setSuggestOpen(false); @@ -479,7 +514,7 @@ function FilterBar({ + {value === "" && ( + {modKbd} + )} {open && (
    {drillEmptyMessage}

    } + {drillItems.length === 0 && } {drillItems.length > 0 && drill.truncated && (

    {view.project @@ -1159,13 +1197,15 @@ function ScreenshotsByPathInner({ !view.merged && overview.latest.length === 0 && overview.catalog.length === 0 ? ( ) : ( -

    - {overview.latest.length === 0 - ? view.merged - ? "No merged uploads to show." - : "No recent uploads to show." - : "No recent uploads match this filter."} -

    + ) ) : ( <> @@ -1243,7 +1283,7 @@ function ScreenshotsByPathInner({ {isEmptyWorkspace ? ( ) : isEmptyFilter ? ( -

    {emptyFilterMessage}

    + ) : ( <> {sectionLabels.map((label, index) => { diff --git a/apps/web/src/components/WorkspaceFileTable.tsx b/apps/web/src/components/WorkspaceFileTable.tsx index 84ea8c1c..318b2c9f 100644 --- a/apps/web/src/components/WorkspaceFileTable.tsx +++ b/apps/web/src/components/WorkspaceFileTable.tsx @@ -19,6 +19,8 @@ */ import { Callout } from "@uploads/ui"; import "@uploads/ui/styles.css"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@uploads/ui/components/ui/empty"; +import { Kbd } from "@uploads/ui/components/ui/kbd"; import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { IslandErrorBoundary } from "./IslandErrorBoundary"; import type { ConnectedWorkSetter } from "../lib/workspace-rail"; @@ -800,6 +802,39 @@ function WorkspaceFileTableInner({ }; }, []); + // "Ctrl K" is the deterministic default so the server's render and the + // client's first render agree bit-for-bit (no `navigator` at SSR time) — + // corrected to "⌘K" after mount, once hydration has already reconciled. + const [modKbd, setModKbd] = useState("Ctrl K"); + useEffect(() => { + if (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) setModKbd("⌘K"); + }, []); + + // "/" or ⌘K/Ctrl+K focuses the filter input from anywhere on the page, + // mirroring ScreenshotsByPath's path-filter shortcut. + useEffect(() => { + const typingInField = (event: KeyboardEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return false; + return Boolean(target.closest("input, textarea, select") || target.isContentEditable); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; + if ((event.key === "k" || event.key === "K") && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + document.getElementById("wft-filter-input")?.focus(); + return; + } + if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (typingInField(event)) return; + event.preventDefault(); + document.getElementById("wft-filter-input")?.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + // Close the open `⋯` menu on outside click / Escape. useEffect(() => { if (!openMenuKey) return; @@ -1020,6 +1055,9 @@ function WorkspaceFileTableInner({ parts.push(pluralCount(count, "file")); return parts.join(" · "); })(); + // True "nothing here" states — as opposed to loading/error/truncated/ + // non-empty end-of-list markers, which stay plain `.wft-end` text. + const isEmptyState = state.status === "ok" && count === 0 && (filtered || folderCount === 0); const parsedDraft = parseDraft(draft); // Only treat the parsed key as "selected" once it's a valid (lowercase) key — @@ -1190,8 +1228,9 @@ function WorkspaceFileTableInner({ addFilter(); }} > - + { // Cancel a still-armed blur-close from a quick refocus — otherwise @@ -1269,6 +1310,9 @@ function WorkspaceFileTableInner({ } }} /> + {draft === "" && ( + {modKbd} + )}
)} -
{endLabel}
+ {isEmptyState ? ( + + + {endLabel} + + {filtered + ? "Try removing a filter or searching a different name." + : "Files uploaded to this workspace will show up here."} + + + + ) : ( +
{endLabel}
+ )} {state.status === "ok" && state.cursor && (