awa-ui 0.3.0

Web UI and JSON API for the Awa job queue
Documentation
import { Outlet, useRouterState } from "@tanstack/react-router";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { useTheme, type Theme } from "@/hooks/use-theme";
import { Button } from "@/components/ui/button";
import {
  NavbarProvider,
  Navbar,
  NavbarSection,
  NavbarItem,
  NavbarLabel,
  NavbarSpacer,
  NavbarMobile,
  NavbarTrigger,
  NavbarStart,
} from "@/components/ui/navbar";

/**
 * Koru-river logo — a Maori koru (unfurling fern spiral) with
 * flowing water lines beneath, evoking "awa" (river).
 */
function KoruLogo({ className }: { className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 36 36"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
      aria-hidden="true"
    >
      <path
        d="M18 4 C10 4, 5 10, 5 17 C5 24, 10 30, 18 30"
        stroke="currentColor"
        strokeWidth="2.8"
        strokeLinecap="round"
        fill="none"
      />
      <path
        d="M18 30 C22 30, 26 26, 26 21 C26 16, 22 13, 18 13"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        fill="none"
      />
      <path
        d="M18 13 C16 13, 14 14.5, 14 17 C14 19, 15.5 20.5, 18 20.5"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        fill="none"
      />
      <circle cx="18" cy="17" r="1.8" fill="currentColor" />
      <path
        d="M3 32 C8 30, 14 34, 20 31 C26 28, 30 32, 34 30"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        fill="none"
        opacity="0.6"
      />
      <path
        d="M5 35 C10 33.5, 15 36.5, 21 34 C27 31.5, 31 34.5, 33 33.5"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        fill="none"
        opacity="0.35"
      />
    </svg>
  );
}

/** Sun icon for light mode */
function SunIcon() {
  return (
    <svg data-slot="icon" viewBox="0 0 20 20" fill="currentColor">
      <path d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 15ZM10 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM15.657 5.404a.75.75 0 1 0-1.06-1.06l-1.061 1.06a.75.75 0 0 0 1.06 1.061l1.061-1.06ZM6.464 14.596a.75.75 0 1 0-1.06-1.06l-1.06 1.06a.75.75 0 0 0 1.06 1.06l1.06-1.06ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM5 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 5 10ZM14.596 15.657a.75.75 0 0 0 1.06-1.06l-1.06-1.061a.75.75 0 1 0-1.06 1.06l1.06 1.061ZM5.404 6.464a.75.75 0 0 0 1.06-1.06l-1.06-1.06a.75.75 0 1 0-1.061 1.06l1.06 1.06Z" />
    </svg>
  );
}

/** Moon icon for dark mode */
function MoonIcon() {
  return (
    <svg data-slot="icon" viewBox="0 0 20 20" fill="currentColor">
      <path fillRule="evenodd" d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z" clipRule="evenodd" />
    </svg>
  );
}

/** Monitor icon for system theme */
function MonitorIcon() {
  return (
    <svg data-slot="icon" viewBox="0 0 20 20" fill="currentColor">
      <path fillRule="evenodd" d="M2 4.25A2.25 2.25 0 0 1 4.25 2h11.5A2.25 2.25 0 0 1 18 4.25v8.5A2.25 2.25 0 0 1 15.75 15h-3.105a3.501 3.501 0 0 1 1.1 1.677A.75.75 0 0 1 13.026 17H6.974a.75.75 0 0 1-.719-.323 3.501 3.501 0 0 1 1.1-1.677H4.25A2.25 2.25 0 0 1 2 12.75v-8.5Zm1.5 0a.75.75 0 0 1 .75-.75h11.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75v-7.5Z" clipRule="evenodd" />
    </svg>
  );
}

/** Cycles through system → light → dark */
function ThemeToggle() {
  const { theme, updateTheme } = useTheme();

  const next: Record<Theme, Theme> = {
    system: "light",
    light: "dark",
    dark: "system",
  };

  const label: Record<Theme, string> = {
    system: "Theme: system",
    light: "Theme: light",
    dark: "Theme: dark",
  };

  return (
    <Button
      intent="plain"
      size="sq-sm"
      aria-label={label[theme]}
      onPress={() => updateTheme(next[theme])}
    >
      {theme === "light" && <SunIcon />}
      {theme === "dark" && <MoonIcon />}
      {theme === "system" && <MonitorIcon />}
    </Button>
  );
}

/** Refresh button with live indicator — click to force refresh all queries */
function RefreshControl() {
  const isFetching = useIsFetching();
  const queryClient = useQueryClient();
  const fetching = isFetching > 0;

  return (
    <Button
      intent="plain"
      size="sq-sm"
      aria-label="Refresh data"
      onPress={() => void queryClient.invalidateQueries()}
      className="relative"
    >
      <svg
        data-slot="icon"
        viewBox="0 0 20 20"
        fill="currentColor"
        className={fetching ? "animate-spin" : ""}
      >
        <path
          fillRule="evenodd"
          d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H4.598a.75.75 0 0 0-.75.75v3.634a.75.75 0 0 0 1.5 0v-2.033l.312.311a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.768a.75.75 0 0 0 0 1.5h3.634a.75.75 0 0 0 .75-.75V3.535a.75.75 0 0 0-1.5 0v2.033l-.312-.31A7 7 0 0 0 2.628 8.395a.75.75 0 0 0 1.449.39l.611.789Z"
          clipRule="evenodd"
        />
      </svg>
      {/* Live indicator dot */}
      {fetching && (
        <span className="absolute -right-0.5 -top-0.5 flex size-2">
          <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-50" />
          <span className="relative inline-flex size-2 rounded-full bg-primary" />
        </span>
      )}
    </Button>
  );
}

const NAV_ITEMS = [
  { to: "/", label: "Dashboard" },
  { to: "/jobs", label: "Jobs" },
  { to: "/queues", label: "Queues" },
  { to: "/cron", label: "Cron" },
] as const;

export function Shell() {
  const routerState = useRouterState();
  const currentPath = routerState.location.pathname;

  function isActive(to: string): boolean {
    if (to === "/") return currentPath === "/";
    return currentPath.startsWith(to);
  }

  return (
    <NavbarProvider>
      <Navbar isSticky>
        <NavbarStart>
          <a href="/" className="flex items-center gap-2 no-underline">
            <KoruLogo className="size-7 text-primary" />
            <span className="text-lg font-semibold tracking-tight text-fg">
              AWA
            </span>
          </a>
        </NavbarStart>

        <NavbarSection className="ml-6">
          {NAV_ITEMS.map((item) => (
            <NavbarItem
              key={item.to}
              href={item.to}
              isCurrent={isActive(item.to)}
            >
              <NavbarLabel>{item.label}</NavbarLabel>
            </NavbarItem>
          ))}
        </NavbarSection>

        <NavbarSpacer />

        <div className="flex items-center gap-2">
          <RefreshControl />
          <ThemeToggle />
        </div>
      </Navbar>

      <NavbarMobile>
        <NavbarTrigger />
        <a href="/" className="flex items-center gap-2 no-underline">
          <KoruLogo className="size-6 text-primary" />
          <span className="text-base font-semibold text-fg">AWA</span>
        </a>
        <div className="ml-auto flex items-center gap-2">
          <RefreshControl />
          <ThemeToggle />
        </div>
      </NavbarMobile>

      <main className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
        <Outlet />
      </main>
    </NavbarProvider>
  );
}