{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kv-browser",
  "title": "KV browser",
  "description": "A KV namespace's keys, filtered by prefix or search, beside the selected key's value, metadata and expiry, with copy and a two-step delete that can be undone.",
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/button.json",
    "https://sproutboat.com/ikada/r/cn.json",
    "https://sproutboat.com/ikada/r/code-block.json",
    "https://sproutboat.com/ikada/r/copy-id.json",
    "https://sproutboat.com/ikada/r/description-list.json",
    "https://sproutboat.com/ikada/r/format.json",
    "https://sproutboat.com/ikada/r/icons.json",
    "https://sproutboat.com/ikada/r/inline-confirm.json",
    "https://sproutboat.com/ikada/r/input.json",
    "https://sproutboat.com/ikada/r/segmented-control.json",
    "https://sproutboat.com/ikada/r/table.json"
  ],
  "files": [
    {
      "path": "registry/ikada/blocks/kv-browser/kv-browser.tsx",
      "content": "\"use client\";\n\n/**\n * A KV namespace's keys, filtered by prefix or search, beside the selected key's value,\n * metadata and expiry, with copy and a two-step delete that can be undone.\n * Wide containers put the list and the key side by side; narrow ones stack them.\n */\nimport { useId, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent } from \"react\";\nimport { Button } from \"@/components/ikada/button\";\nimport { CodeBlock } from \"@/components/ikada/code-block\";\nimport { CopyId } from \"@/components/ikada/copy-id\";\nimport { DescriptionList } from \"@/components/ikada/description-list\";\nimport { InlineConfirm } from \"@/components/ikada/inline-confirm\";\nimport { Input } from \"@/components/ikada/input\";\nimport { SegmentedControl } from \"@/components/ikada/segmented-control\";\nimport { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from \"@/components/ikada/table\";\nimport { cn, focusRing, pressMotion } from \"@/components/ikada/lib/cn\";\nimport { formatBytes } from \"@/components/ikada/lib/format\";\nimport { CheckIcon, KeyIcon, SearchIcon, TrashIcon, UndoIcon } from \"@/components/ikada/lib/icons\";\n\nexport interface KvEntry {\n  key: string;\n  /** The stored value as text; JSON is pretty-printed in the detail pane. */\n  value: string;\n  /** Optional metadata stored with the key (`put(key, value, { metadata })`). */\n  metadata?: Record<string, unknown>;\n  /** Seconds until the key expires; omit for keys that never expire. */\n  expiresIn?: number;\n  /** Seconds since the key was last written. */\n  updatedAgo: number;\n}\n\nexport interface KvBrowserProps {\n  /** The namespace name shown in the header. */\n  namespace?: string;\n  entries?: KvEntry[];\n  /** Prefixes offered as quick filters. Defaults to the distinct `prefix:` parts of the keys. */\n  prefixes?: string[];\n  /** Called after a key is deleted (and again with `restored` when the delete is undone). */\n  onDelete?: (key: string, restored?: boolean) => void;\n  className?: string;\n}\n\nconst json = (value: unknown): string => JSON.stringify(value);\n\n/** Sample data: a namespace a small shop's edge code reads for sessions, flags, short links and rate limits. */\nexport const sampleEntries: KvEntry[] = [\n  {\n    key: \"flag:checkout-v2\",\n    value: json({ enabled: true, rollout: 0.25, audience: [\"beta\", \"staff\"] }),\n    metadata: { owner: \"payments\" },\n    updatedAgo: 3 * 3600,\n  },\n  { key: \"flag:dark-mode\", value: json({ enabled: true, rollout: 1 }), updatedAgo: 9 * 86400 },\n  {\n    key: \"flag:new-search\",\n    value: json({ enabled: false, rollout: 0, note: \"waiting on index rebuild\" }),\n    metadata: { owner: \"search\" },\n    updatedAgo: 26 * 3600,\n  },\n  {\n    key: \"link:spring-sale\",\n    value: \"https://fernleaf.shop/collections/spring?utm_source=newsletter&utm_campaign=spring-sale\",\n    metadata: { clicks: 1284 },\n    updatedAgo: 4 * 86400,\n  },\n  {\n    key: \"link:care-guide\",\n    value: \"https://fernleaf.shop/guides/watering-101\",\n    metadata: { clicks: 341 },\n    updatedAgo: 20 * 86400,\n  },\n  { key: \"link:returns\", value: \"https://fernleaf.shop/help/returns\", updatedAgo: 61 * 86400 },\n  {\n    key: \"rate:203.0.113.42\",\n    value: json({ count: 87, window: \"60s\" }),\n    expiresIn: 38,\n    updatedAgo: 22,\n  },\n  { key: \"rate:198.51.100.7\", value: json({ count: 12, window: \"60s\" }), expiresIn: 51, updatedAgo: 9 },\n  {\n    key: \"session:01J9ZK4Q8M2R\",\n    value: json({\n      user: \"u_4821\",\n      email: \"mara@example.com\",\n      cart: [\"monstera-deliciosa\", \"ceramic-pot-m\"],\n      theme: \"dark\",\n    }),\n    metadata: { ip: \"203.0.113.42\", ua: \"Safari 18\" },\n    expiresIn: 6 * 3600 + 1200,\n    updatedAgo: 4 * 60,\n  },\n  {\n    key: \"session:01J9ZM7C3T5W\",\n    value: json({ user: \"u_1093\", email: \"theo@example.com\", cart: [], theme: \"light\" }),\n    metadata: { ip: \"198.51.100.7\", ua: \"Firefox 131\" },\n    expiresIn: 23 * 3600,\n    updatedAgo: 52 * 60,\n  },\n  {\n    key: \"session:01J9ZP1D9H0X\",\n    value: json({ user: \"u_2210\", email: \"ines@example.com\", cart: [\"pilea-peperomioides\"], theme: \"system\" }),\n    expiresIn: 2 * 3600,\n    updatedAgo: 3 * 3600,\n  },\n  {\n    key: \"stock:monstera-deliciosa\",\n    value: json({ sku: \"PL-0142\", available: 18, reserved: 3, warehouse: \"lis-1\" }),\n    expiresIn: 300,\n    updatedAgo: 40,\n  },\n  {\n    key: \"stock:pilea-peperomioides\",\n    value: json({ sku: \"PL-0087\", available: 0, reserved: 0, warehouse: \"lis-1\", restock: \"2026-11-20\" }),\n    expiresIn: 300,\n    updatedAgo: 110,\n  },\n  {\n    key: \"config:shipping\",\n    value: json({ free_over: 60, currency: \"EUR\", zones: [\"PT\", \"ES\", \"FR\", \"DE\"] }),\n    updatedAgo: 14 * 86400,\n  },\n];\n\n/** \"38 s\", \"6 h\", \"9 d\": a compact duration for ages and expiries. */\nfunction span(seconds: number): string {\n  if (seconds < 60) return `${Math.round(seconds)} s`;\n  if (seconds < 3600) return `${Math.round(seconds / 60)} min`;\n  if (seconds < 86400) return `${Math.round(seconds / 3600)} h`;\n  return `${Math.round(seconds / 86400)} d`;\n}\n\nconst sizeOf = (entry: KvEntry): number => new TextEncoder().encode(entry.key + entry.value).length;\n\nfunction pretty(text: string): string | null {\n  try {\n    const parsed: unknown = JSON.parse(text);\n    return typeof parsed === \"object\" && parsed !== null ? JSON.stringify(parsed, null, 2) : null;\n  } catch {\n    return null;\n  }\n}\n\nfunction prefixesOf(entries: KvEntry[]): string[] {\n  const seen = new Set<string>();\n  for (const e of entries) {\n    const at = e.key.indexOf(\":\");\n    if (at > 0) seen.add(e.key.slice(0, at + 1));\n  }\n  return [...seen].sort();\n}\n\n/** A toggle chip for one quick filter. */\nfunction Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={active}\n      onClick={onClick}\n      className={cn(\n        \"inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2.5 font-mono text-sb-xs shadow-[inset_0_0_0_1px_var(--sb-hairline-strong)] pointer-coarse:h-11\",\n        pressMotion,\n        active ? \"bg-neutral-soft font-semibold text-fg\" : \"text-fg-muted hover:bg-hover hover:text-fg\",\n        focusRing,\n      )}\n    >\n      {active ? <CheckIcon className=\"size-3\" /> : null}\n      {children}\n    </button>\n  );\n}\n\nexport function KvBrowser({\n  namespace = \"fernleaf-edge\",\n  entries: initial = sampleEntries,\n  prefixes,\n  onDelete,\n  className,\n}: KvBrowserProps) {\n  const [entries, setEntries] = useState(() => [...initial].sort((a, b) => a.key.localeCompare(b.key)));\n  const [query, setQuery] = useState(\"\");\n  const [prefix, setPrefix] = useState<string | null>(null);\n  const [selected, setSelected] = useState<string | null>(() => entries[0]?.key ?? null);\n  const [view, setView] = useState<\"json\" | \"text\">(\"json\");\n  const [armed, setArmed] = useState(false);\n  const [deleted, setDeleted] = useState<{ entry: KvEntry; index: number } | null>(null);\n  const listRef = useRef<HTMLTableSectionElement>(null);\n  const searchId = useId();\n  const chips = useMemo(() => prefixes ?? prefixesOf(entries), [prefixes, entries]);\n\n  const needle = query.trim().toLowerCase();\n  const rows = entries.filter(\n    (e) => (!prefix || e.key.startsWith(prefix)) && (!needle || e.key.toLowerCase().includes(needle)),\n  );\n  const current = entries.find((e) => e.key === selected) ?? null;\n  const filtered = Boolean(prefix || needle);\n\n  function select(key: string) {\n    setSelected(key);\n    setArmed(false);\n  }\n\n  /** Up and down move between keys, Home and End jump to the ends. */\n  function onListKey(event: KeyboardEvent<HTMLButtonElement>, index: number) {\n    const to =\n      event.key === \"ArrowDown\"\n        ? index + 1\n        : event.key === \"ArrowUp\"\n          ? index - 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? rows.length - 1\n              : -1;\n    if (to < 0 || to >= rows.length || to === index) return;\n    event.preventDefault();\n    const next = rows[to];\n    if (!next) return;\n    select(next.key);\n    listRef.current?.querySelectorAll<HTMLButtonElement>(\"[data-kv-key]\")[to]?.focus();\n  }\n\n  function remove(confirm: () => void) {\n    if (!current) return;\n    if (!armed) {\n      setArmed(true);\n      return;\n    }\n    const index = entries.indexOf(current);\n    const rest = entries.filter((e) => e !== current);\n    const visible = rows.filter((e) => e !== current);\n    const next = visible[Math.min(rows.indexOf(current), visible.length - 1)] ?? null;\n    setEntries(rest);\n    setDeleted({ entry: current, index });\n    setSelected(next?.key ?? null);\n    setArmed(false);\n    confirm();\n    onDelete?.(current.key);\n  }\n\n  function undo() {\n    if (!deleted) return;\n    const { entry, index } = deleted;\n    setEntries((list) => [...list.slice(0, index), entry, ...list.slice(index)]);\n    setSelected(entry.key);\n    setDeleted(null);\n    onDelete?.(entry.key, true);\n  }\n\n  const formatted = current ? pretty(current.value) : null;\n\n  return (\n    <section\n      aria-label={`Keys in ${namespace}`}\n      data-density=\"compact\"\n      className={cn(\"@container min-w-0 overflow-hidden rounded-xl bg-surface text-fg shadow-card\", className)}\n    >\n      <header className=\"flex flex-col gap-3 border-b border-hairline p-3 @lg:px-4\">\n        <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n          <span className=\"inline-flex min-w-0 items-center gap-2 font-semibold\">\n            <KeyIcon className=\"text-fg-muted\" />\n            <span className=\"truncate font-mono text-sb-sm\" title={namespace}>\n              {namespace}\n            </span>\n          </span>\n          <span className=\"text-sb-sm text-fg-muted tabular-nums\">\n            {entries.length.toLocaleString(\"en\")} keys · {formatBytes(entries.reduce((n, e) => n + sizeOf(e), 0))}\n          </span>\n          <div className=\"relative w-full @2xl:ms-auto @2xl:w-64\">\n            <label htmlFor={searchId} className=\"sr-only\">\n              Search keys\n            </label>\n            <SearchIcon className=\"pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-fg-muted\" />\n            <Input\n              id={searchId}\n              type=\"search\"\n              mono\n              value={query}\n              onChange={(event) => setQuery(event.target.value)}\n              placeholder=\"Search keys\"\n              className=\"ps-9\"\n            />\n          </div>\n        </div>\n        {chips.length ? (\n          <div role=\"group\" aria-label=\"Filter by prefix\" className=\"-mx-1 flex gap-1.5 overflow-x-auto px-1 py-0.5\">\n            <Chip active={prefix === null} onClick={() => setPrefix(null)}>\n              all\n            </Chip>\n            {chips.map((p) => (\n              <Chip key={p} active={prefix === p} onClick={() => setPrefix(prefix === p ? null : p)}>\n                {p}\n              </Chip>\n            ))}\n          </div>\n        ) : null}\n      </header>\n\n      <div className=\"grid min-w-0 @4xl:grid-cols-[minmax(0,1fr)_32rem]\">\n        <div className=\"flex min-w-0 flex-col\">\n          <Table\n            label={`Keys in ${namespace}`}\n            containerClassName=\"rounded-none shadow-none\"\n            maxHeight=\"34rem\"\n            stickyHeader\n          >\n            <TableHeader>\n              <TableRow className=\"hover:bg-transparent\">\n                <TableHead className=\"bg-surface\">Key</TableHead>\n                <TableHead hideBelow=\"sm\" className=\"w-24 bg-surface text-end\">\n                  Size\n                </TableHead>\n                <TableHead className=\"w-24 bg-surface text-end\">Expires</TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody ref={listRef}>\n              {rows.length === 0 ? (\n                <TableEmpty colSpan={3}>\n                  <p className=\"text-sb-sm text-fg-muted\">\n                    {filtered ? \"No keys match this filter.\" : \"This namespace is empty.\"}\n                  </p>\n                </TableEmpty>\n              ) : (\n                rows.map((entry, index) => (\n                  <TableRow\n                    key={entry.key}\n                    selected={entry.key === selected}\n                    className=\"cursor-pointer\"\n                    onClick={() => select(entry.key)}\n                  >\n                    <TableCell className=\"max-w-0 py-1.5\">\n                      <button\n                        type=\"button\"\n                        data-kv-key\n                        aria-current={entry.key === selected ? \"true\" : undefined}\n                        tabIndex={\n                          entry.key === selected || (!rows.some((r) => r.key === selected) && index === 0) ? 0 : -1\n                        }\n                        onClick={() => select(entry.key)}\n                        onKeyDown={(event) => onListKey(event, index)}\n                        title={entry.key}\n                        className={cn(\n                          \"block max-w-full truncate rounded-sm text-start font-mono text-sb-sm\",\n                          entry.key === selected && \"font-semibold\",\n                          focusRing,\n                        )}\n                      >\n                        {entry.key}\n                      </button>\n                    </TableCell>\n                    <TableCell\n                      hideBelow=\"sm\"\n                      className=\"py-1.5 text-end text-sb-sm whitespace-nowrap text-fg-muted tabular-nums\"\n                    >\n                      {formatBytes(sizeOf(entry))}\n                    </TableCell>\n                    <TableCell className=\"py-1.5 text-end text-sb-sm whitespace-nowrap tabular-nums\">\n                      {entry.expiresIn === undefined ? (\n                        <span className=\"text-fg-muted\">never</span>\n                      ) : (\n                        `in ${span(entry.expiresIn)}`\n                      )}\n                    </TableCell>\n                  </TableRow>\n                ))\n              )}\n            </TableBody>\n          </Table>\n          <footer className=\"mt-auto flex min-h-12 flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-hairline px-4 py-2 text-sb-sm text-fg-muted\">\n            <span className=\"tabular-nums\">\n              {filtered ? `${rows.length} of ${entries.length} keys` : `${entries.length} keys, in name order`}\n            </span>\n            {deleted ? (\n              <span className=\"inline-flex min-w-0 items-center gap-2\">\n                <span className=\"truncate\">\n                  Deleted <span className=\"font-mono text-fg\">{deleted.entry.key}</span>\n                </span>\n                <Button size=\"sm\" variant=\"ghost\" onClick={undo}>\n                  <UndoIcon />\n                  Undo\n                </Button>\n              </span>\n            ) : null}\n          </footer>\n        </div>\n\n        <div\n          role=\"region\"\n          aria-label={current ? `Key ${current.key}` : \"Key details\"}\n          className=\"flex min-w-0 flex-col gap-4 border-hairline p-4 @max-4xl:border-t @4xl:border-s\"\n        >\n          {current ? (\n            <>\n              <CopyId\n                value={current.key}\n                display={current.key}\n                label=\"Copy key name\"\n                className=\"min-w-0 font-semibold\"\n              />\n              <div className=\"flex flex-wrap items-center justify-between gap-2\">\n                <span className=\"text-sb-sm text-fg-muted\">Value</span>\n                {formatted ? (\n                  <SegmentedControl\n                    aria-label=\"Show the value as\"\n                    size=\"sm\"\n                    value={view}\n                    onValueChange={setView}\n                    options={[\n                      { value: \"json\", label: \"JSON\" },\n                      { value: \"text\", label: \"Raw\" },\n                    ]}\n                  />\n                ) : null}\n              </div>\n              <CodeBlock\n                label={`Value of ${current.key}`}\n                code={formatted && view === \"json\" ? formatted : current.value}\n                wrap\n              />\n              <DescriptionList\n                items={[\n                  { id: \"size\", term: \"Size\", details: formatBytes(sizeOf(current)) },\n                  {\n                    id: \"expires\",\n                    term: \"Expires\",\n                    details: current.expiresIn === undefined ? \"Never\" : `In ${span(current.expiresIn)}`,\n                  },\n                  { id: \"updated\", term: \"Updated\", details: `${span(current.updatedAgo)} ago` },\n                  {\n                    id: \"metadata\",\n                    term: \"Metadata\",\n                    mono: Boolean(current.metadata),\n                    details: current.metadata ? json(current.metadata) : <span className=\"text-fg-muted\">None</span>,\n                  },\n                ]}\n              />\n              <div className=\"mt-auto flex flex-wrap items-center justify-end gap-2 pt-1\">\n                {armed ? (\n                  <Button size=\"sm\" variant=\"ghost\" onClick={() => setArmed(false)}>\n                    Cancel\n                  </Button>\n                ) : null}\n                <InlineConfirm message=\"Deleted\" announce=\"Key deleted\">\n                  {({ confirm }) => (\n                    <Button\n                      size=\"sm\"\n                      variant={armed ? \"danger\" : \"ghost\"}\n                      className={cn(!armed && \"text-danger-fg hover:text-danger-fg\")}\n                      onClick={() => remove(confirm)}\n                      onBlur={() => setArmed(false)}\n                    >\n                      <TrashIcon />\n                      {armed ? \"Click again to delete\" : \"Delete key\"}\n                    </Button>\n                  )}\n                </InlineConfirm>\n              </div>\n            </>\n          ) : (\n            <div className=\"flex min-h-40 flex-1 items-center justify-center text-center text-sb-sm text-fg-muted\">\n              Pick a key to see its value, metadata and expiry.\n            </div>\n          )}\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/ikada/blocks/kv-browser/kv-browser.tsx"
    }
  ],
  "categories": [
    "dashboard"
  ],
  "type": "registry:block"
}