Skip to content

Project tray

A recessed tray per project holding its sprouts as floating cards: each card has a status tile, the hostname and one state line, and an empty project offers to plant its first sprout.

Base UI3.2 kB gzipSource Registry JSON

Preview Live, with the sample data the block ships with.

components/ikada/blocks/project-tray/project-tray.tsx

"use client";

/**
 * A recessed tray per project holding its sprouts as floating cards: each card has a status tile,
 * the hostname and one state line, and an empty project offers to plant its first sprout.
 */
import { buttonStyles } from "@/components/ikada/button";
import { StatusDot } from "@/components/ikada/status-dot";
import type { Status } from "@/components/ikada/status";
import { cn, focusRing } from "@/components/ikada/lib/cn";
import { formatCompact } from "@/components/ikada/lib/format";
import { ChevronRightIcon, PlusIcon } from "@/components/ikada/lib/icons";

export type SproutStatus = "live" | "deploying" | "deploy_failed" | "paused" | "not_deployed";

export interface Sprout {
  slug: string;
  name: string;
  hostname: string;
  status: SproutStatus;
  /** What wakes it: "http" for a web sprout, or "cron", "queue". Anything but http shows as a tag. */
  trigger?: string;
  /** The version serving traffic, if any. */
  liveVersion?: number;
  /** When the last deploy finished, already phrased: "12 min ago". */
  lastDeploy?: string;
  requests24h?: number;
}

export interface Project {
  slug: string;
  name: string;
  sprouts: Sprout[];
}

export interface ProjectTrayProps {
  projects?: Project[];
  /** Link to a project page. */
  projectHref?: (project: Project) => string;
  /** Link to a sprout page. */
  sproutHref?: (project: Project, sprout: Sprout) => string;
  /** Called by the "Sprout" button and the empty tray. */
  onNewSprout?: (project: Project) => void;
  className?: string;
}

/** Sample data: two projects with a mix of states, and a third that is still empty. */
export const sampleProjects: Project[] = [
  {
    slug: "pond",
    name: "Pond",
    sprouts: [
      {
        slug: "pond-web",
        name: "pond-web",
        hostname: "pond-web.lily.sproutboat.app",
        status: "live",
        liveVersion: 48,
        lastDeploy: "12 min ago",
        requests24h: 184_200,
      },
      {
        slug: "pond-api",
        name: "pond-api",
        hostname: "pond-api.lily.sproutboat.app",
        status: "deploying",
        liveVersion: 31,
        requests24h: 96_400,
      },
      {
        slug: "nightly-digest",
        name: "nightly-digest",
        hostname: "nightly-digest.lily.sproutboat.app",
        status: "live",
        trigger: "cron",
        liveVersion: 7,
        lastDeploy: "3 days ago",
      },
      {
        slug: "thumbnailer",
        name: "thumbnailer",
        hostname: "thumbnailer.lily.sproutboat.app",
        status: "deploy_failed",
        trigger: "queue",
        liveVersion: 12,
      },
    ],
  },
  {
    slug: "lily-pad",
    name: "Lily pad",
    sprouts: [
      {
        slug: "lily-pad-web",
        name: "lily-pad-web",
        hostname: "lily-pad-web.lily.sproutboat.app",
        status: "live",
        liveVersion: 5,
        lastDeploy: "yesterday",
        requests24h: 2_310,
      },
      {
        slug: "lily-pad-preview",
        name: "lily-pad-preview",
        hostname: "lily-pad-preview.lily.sproutboat.app",
        status: "paused",
      },
    ],
  },
  { slug: "reed", name: "Reed", sprouts: [] },
];

const look: Record<SproutStatus, { status: Status; live?: boolean; pulse?: boolean }> = {
  live: { status: "success", live: true },
  deploying: { status: "info", pulse: true },
  deploy_failed: { status: "danger" },
  paused: { status: "neutral" },
  not_deployed: { status: "neutral" },
};

/** "Live · deployed 12 min ago", "Deploy failed · v12 serving": the one line a card needs. */
export function stateLine(s: Sprout): string {
  switch (s.status) {
    case "live":
      return s.lastDeploy ? `Live · deployed ${s.lastDeploy}` : "Live";
    case "deploying":
      return s.liveVersion ? `Deploying · v${s.liveVersion} still live` : "Deploying the first version";
    case "deploy_failed":
      return s.liveVersion ? `Deploy failed · v${s.liveVersion} serving` : "Deploy failed";
    case "paused":
      return "Paused · answers 503";
    case "not_deployed":
      return "Not deployed yet";
  }
}

/** The sprout leaf on a soft tile, tinted by status. */
function SproutTile({ status }: { status: Status }) {
  const surface = {
    success: "bg-brand-soft text-brand-fg",
    info: "bg-info-soft text-info-fg",
    danger: "bg-danger-soft text-danger-fg",
    warning: "bg-warn-soft text-warn-fg",
    neutral: "bg-neutral-soft text-fg-muted",
  }[status];
  return (
    <span aria-hidden="true" className={cn("grid size-7 shrink-0 place-items-center rounded-lg", surface)}>
      <svg viewBox="3 6 24 25" className="size-4" fill="none">
        <path d="M16 29V15" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round" />
        <path
          d="M16 16.5c0-4.7-3.8-8.5-8.5-8.5C6.7 8 6 8.7 6 9.5 6 14.2 9.8 18 14.5 18c.8 0 1.5-.7 1.5-1.5Z"
          fill="currentColor"
        />
        <path
          d="M16 15c0-4 3.2-7.2 7.2-7.2.7 0 1.3.6 1.3 1.3C24.5 12.8 21.3 16 17.3 16c-.7 0-1.3-.6-1.3-1Z"
          fill="currentColor"
          fillOpacity="0.72"
        />
      </svg>
    </span>
  );
}

function SproutCard({ sprout, href }: { sprout: Sprout; href: string }) {
  const l = look[sprout.status];
  return (
    <a
      href={href}
      className={cn(
        "flex min-w-0 flex-col gap-2.5 rounded-[0.6875rem] bg-surface p-3.5 pb-3 text-fg shadow-card",
        "transition-shadow duration-(--sb-duration-hover) ease-hover hover:shadow-float",
        focusRing,
      )}
    >
      <span className="flex min-w-0 items-center gap-2">
        <SproutTile status={l.status} />
        <span className="min-w-0 flex-1 truncate text-sb-base font-semibold" title={sprout.name}>
          {sprout.name}
        </span>
        {sprout.trigger && sprout.trigger !== "http" ? (
          <span
            title={sprout.trigger}
            className="max-w-[40%] shrink-0 truncate rounded-md bg-neutral-soft px-1.5 py-0.5 font-mono text-sb-xs font-semibold text-neutral-fg"
          >
            {sprout.trigger}
          </span>
        ) : null}
      </span>
      <span className="truncate font-mono text-sb-xs text-fg-muted" title={sprout.hostname}>
        {sprout.hostname}
      </span>
      <span className="flex min-w-0 border-t border-hairline pt-2.5">
        <StatusDot
          tone={l.status}
          live={l.live}
          pulse={l.pulse}
          label={stateLine(sprout)}
          className={cn(
            "min-w-0 truncate text-sb-sm",
            sprout.status === "deploy_failed" ? "text-danger-fg" : "text-fg-muted",
          )}
        />
      </span>
    </a>
  );
}

const grid = "grid grid-cols-[repeat(auto-fill,minmax(12.75rem,1fr))] gap-1.5";

function Tray({
  project,
  href,
  sproutHref,
  onNewSprout,
}: {
  project: Project;
  href: string;
  sproutHref: (sprout: Sprout) => string;
  onNewSprout: () => void;
}) {
  const requests = project.sprouts.reduce((sum, s) => sum + (s.requests24h ?? 0), 0);
  const id = `tray-${project.slug}`;
  const count = project.sprouts.length;
  return (
    <section
      aria-labelledby={id}
      className="@container rounded-2xl bg-well p-1.5 shadow-[inset_0_0_0_1px_var(--sb-hairline)]"
    >
      <div className="flex min-w-0 items-center gap-2.5 px-3 pt-2 pb-2.5">
        <h2 id={id} title={project.name} className="min-w-0 truncate text-sb-md font-semibold">
          <a href={href} className={cn("rounded-sm hover:underline", focusRing)}>
            {project.name}
          </a>
        </h2>
        <span className="shrink-0 text-sb-sm text-fg-muted">
          {count} {count === 1 ? "sprout" : "sprouts"}
        </span>
        <span className="ms-auto flex shrink-0 items-center gap-1">
          {requests > 0 ? (
            <span className="me-2 hidden text-sb-sm text-fg-muted tabular-nums @xl:inline">
              {formatCompact(requests)} requests, 24 h
            </span>
          ) : null}
          <button
            type="button"
            onClick={onNewSprout}
            className={buttonStyles({ variant: "ghost", size: "sm" })}
            aria-label={`New sprout in ${project.name}`}
          >
            <PlusIcon />
            Sprout
          </button>
          <a
            href={href}
            className={cn(buttonStyles({ variant: "ghost", size: "sm" }), "size-(--sb-control-h-sm) px-0")}
            aria-label={`Open ${project.name}`}
          >
            <ChevronRightIcon />
          </a>
        </span>
      </div>
      {count ? (
        <div className={grid}>
          {project.sprouts.map((s) => (
            <SproutCard key={s.slug} sprout={s} href={sproutHref(s)} />
          ))}
        </div>
      ) : (
        <button
          type="button"
          onClick={onNewSprout}
          className={cn(
            "flex min-h-24 w-full flex-col items-center justify-center gap-1 rounded-[0.6875rem] p-4 text-center text-sb-sm text-fg-muted",
            "border border-dashed border-hairline-strong",
            "transition-colors duration-(--sb-duration-hover) ease-hover hover:bg-hover hover:text-fg",
            focusRing,
          )}
        >
          <span className="inline-flex items-center gap-1.5 font-semibold text-fg">
            <PlusIcon />
            Plant the first sprout
          </span>
          Start from a template, a repository or the CLI.
        </button>
      )}
    </section>
  );
}

export function ProjectTray({
  projects = sampleProjects,
  projectHref = (p) => `#${p.slug}`,
  sproutHref = (p, s) => `#${p.slug}/${s.slug}`,
  onNewSprout = () => {},
  className,
}: ProjectTrayProps) {
  return (
    <div className={cn("flex flex-col gap-4", className)}>
      {projects.map((project) => (
        <Tray
          key={project.slug}
          project={project}
          href={projectHref(project)}
          sproutHref={(s) => sproutHref(project, s)}
          onNewSprout={() => onNewSprout(project)}
        />
      ))}
    </div>
  );
}

Installation

npx shadcn@latest add https://sproutboat.com/ikada/r/project-tray.json

Copies components/ikada/blocks/project-tray/project-tray.tsx into your project with the ikada theme. It is your code from then on: rename it, restyle it, and feed it your own data through its props.

Built from

Props

ProjectTrayProps
PropTypeDescription
projectsProject[]Default `[ { slug: "pond", name: "Pond", sprouts: [ { slug: "pond-web", name: "pond-web", hostname: "pond-web.lily.sproutboat.app", status: "live", liveVersion: 48, lastDeploy: "12 min ago", requests24h: 184_200, }, { slug: "pond-api", name: "pond-api", hostname: "pond-api.lily.sproutboat.app", status: "deploying", liveVersion: 31, requests24h: 96_400, }, { slug: "nightly-digest", name: "nightly-digest", hostname: "nightly-digest.lily.sproutboat.app", status: "live", trigger: "cron", liveVersion: 7, lastDeploy: "3 days ago", }, { slug: "thumbnailer", name: "thumbnailer", hostname: "thumbnailer.lily.sproutboat.app", status: "deploy_failed", trigger: "queue", liveVersion: 12, }, ], }, { slug: "lily-pad", name: "Lily pad", sprouts: [ { slug: "lily-pad-web", name: "lily-pad-web", hostname: "lily-pad-web.lily.sproutboat.app", status: "live", liveVersion: 5, lastDeploy: "yesterday", requests24h: 2_310, }, { slug: "lily-pad-preview", name: "lily-pad-preview", hostname: "lily-pad-preview.lily.sproutboat.app", status: "paused", }, ], }, { slug: "reed", name: "Reed", sprouts: [] }, ]`.
projectHref((project: Project) => string)Link to a project page. Default `(p) => `#${p.slug}``.
sproutHref((project: Project, sprout: Sprout) => string)Link to a sprout page. Default `(p, s) => `#${p.slug}/${s.slug}``.
onNewSprout((project: Project) => void)Called by the "Sprout" button and the empty tray. Default `() => {}`.
classNamestringClasses merged onto the root element, after ikada's own.