zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { cx, useState } from "zeb/react";
import Input from "@/components/ui/input";
import Button from "@/components/ui/button";
import HubItemRow from "@/components/hub/hub-item-row";
import HubItemDetail from "@/components/hub/hub-item-detail";
import { HUB_KINDS, HUB_KIND_CLASS_HINT, kindOf } from "@/components/hub/hub-kinds";

/**
 * Which packages the reader is looking at.
 *
 * State comes first because it is the question people actually arrive with —
 * "do I already have this" before "what type is it". Only installed kinds can
 * answer it: added content is copied into the repository and recorded nowhere,
 * so it is always simply available.
 */
const STATES = [
  { id: "project", label: "In project" },
  { id: "available", label: "Available" },
  { id: "updatable", label: "Updatable" },
  { id: "problems", label: "Problems" },
];

function matchesState(item, state) {
  if (state === "project") return !!item?.installed;
  if (state === "updatable") return !!item?.updatable;
  if (state === "problems") return !!item?.status && item.status !== "resolved";
  return !item?.installed;
}

function matchesSearch(item, text) {
  if (!text) return true;
  const needle = text.toLowerCase();
  return [item?.title, item?.package_id, item?.summary, item?.description]
    .filter(Boolean)
    .some((field) => String(field).toLowerCase().includes(needle));
}

/**
 * The one catalogue.
 *
 * The Hub page and the editor's "Add from hub" dialog are the same list, the
 * same filters and the same consequences — they differ only in where they open
 * and whether a destination is already known. Building it twice is how they
 * drifted into two vocabularies for one idea.
 */
export default function HubBrowser({ items, owner, project, initialState, destination, onDestinationChange, onAct, busy }) {
  const [state, setState] = useState(initialState || "available");
  const [kind, setKind] = useState("");
  const [search, setSearch] = useState("");
  const [selectedId, setSelectedId] = useState("");

  const all = Array.isArray(items) ? items : [];
  const visible = all.filter(
    (item) =>
      matchesState(item, state) &&
      (!kind || item?.asset_kind === kind) &&
      matchesSearch(item, search),
  );
  // The reader's choice survives a filter that stops matching it. Installing a
  // package moves it out of "Available", and looking only at `visible` meant
  // the pane silently switched to an unrelated package the moment the install
  // finished — so the Remove action, which had just become available, appeared
  // to be missing.
  const selected =
    all.find((item) => item?.package_id === selectedId) || visible[0] || null;
  const problemCount = all.filter((item) => item?.status && item.status !== "resolved").length;

  return (
    <div className="flex min-h-0 flex-1 flex-col" data-hub-browser="true">
      {/* tw-variants hint: per-kind colours are chosen at runtime from the kind
          table, so they never appear in scanned markup on their own. */}
      <span hidden tw-variants={HUB_KIND_CLASS_HINT} />

      <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
        <Input
          className="min-w-[14rem] flex-1"
          value={search}
          placeholder="Search packages…"
          onInput={(event) => setSearch(event.currentTarget.value)}
        />
        <div className="flex items-center gap-1">
          {STATES.map((entry) => (
            <button
              key={entry.id}
              type="button"
              data-hub-state={entry.id}
              onClick={() => setState(entry.id)}
              className={cx(
                "rounded px-2.5 py-1 text-[0.72rem] font-medium transition-colors",
                state === entry.id
                  ? "bg-accent text-foreground"
                  : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
              )}
            >
              {entry.label}
              {entry.id === "problems" && problemCount ? (
                <span className="ml-1 text-warning">{problemCount}</span>
              ) : null}
            </button>
          ))}
        </div>
      </div>

      <div className="flex flex-wrap items-center gap-1.5 border-b border-border px-3 py-2">
        <button
          type="button"
          onClick={() => setKind("")}
          className={cx(
            "rounded-full border px-2.5 py-[3px] text-[0.7rem] transition-colors",
            kind ? "border-border text-muted-foreground hover:text-foreground" : "border-border bg-accent text-foreground",
          )}
        >
          All
        </button>
        {HUB_KINDS.map((entry) => (
          <button
            key={entry.id}
            type="button"
            data-hub-kind-chip={entry.id}
            onClick={() => setKind(kind === entry.id ? "" : entry.id)}
            className={cx(
              "flex items-center gap-1.5 rounded-full border px-2.5 py-[3px] text-[0.7rem] transition-colors",
              kind === entry.id ? entry.chipOn : "border-border text-muted-foreground hover:text-foreground",
            )}
          >
            <span className={cx("leading-none", kind === entry.id ? "" : entry.tone)} aria-hidden="true">
              {entry.glyph}
            </span>
            {entry.label}
          </button>
        ))}
      </div>

      {state === "problems" && problemCount ? (
        <div className="flex items-center justify-between gap-3 border-b border-warning/40 bg-warning/5 px-3 py-2">
          <p className="text-[0.74rem] text-muted-foreground">
            <span className="font-medium text-warning">{problemCount}</span> installed package(s)
            do not match <code className="font-mono">zeb.lock</code> — missing, or the bytes changed.
          </p>
          <Button type="button" variant="outline" size="sm" onClick={() => onAct({ repair_all: true })}>
            Repair all
          </Button>
        </div>
      ) : null}

      <div className="grid flex-1 items-start grid-cols-[minmax(0,1fr)_minmax(0,22rem)]">
        <div className="border-r border-border">
          {visible.length ? (
            visible.map((item) => (
              <HubItemRow
                key={item?.package_id}
                item={item}
                selected={selected?.package_id === item?.package_id}
                onSelect={(next) => setSelectedId(next?.package_id)}
              />
            ))
          ) : (
            <p className="px-3 py-6 text-center text-[0.76rem] text-muted-foreground">
              {kind ? `No ${kindOf(kind).label.toLowerCase()} here.` : "Nothing matches."}
            </p>
          )}
        </div>

        <div className="min-h-0 self-start sticky top-0 max-h-[calc(100dvh-8rem)] overflow-y-auto">
          <HubItemDetail
            item={selected}
            owner={owner}
            project={project}
            destination={destination}
            onDestinationChange={onDestinationChange}
            busy={busy}
            onAct={onAct}
          />
        </div>
      </div>
    </div>
  );
}