From 5ca679cf32c71edc14c395d900e4ac1ba4fd7807 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 20 May 2026 19:36:34 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20module=20detail=20pages,=20?= =?UTF-8?q?=E2=8C=98K=20search,=20dynamic=20OG=20images,=20sitemap,=20and?= =?UTF-8?q?=20SEO=20groundwork=20for=20`apps/web`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `/m/$slot/$id` route (`apps/web/src/routes/m.$slot.$id.tsx`) with `module-detail.ts` server function — full adapter switcher (peer chip rows), deps/devDeps/scripts table, env-var table, templates list with click-to-expand Shiki preview, and a "Try it" copy-command bar; new detail components land under `apps/web/src/components/detail/` - Add ⌘K search popover (`apps/web/src/components/search/site-search.tsx`) backed by `apps/web/src/lib/module-search.ts` — fuzzy-matches id/label/description/slot exactly as the CLI does; wired into the header - Add dynamic OG image generation via `@vercel/og` + Satori: `server/routes/og/[slot]/[id].ts` renders a per-module card, `server/routes/og.ts` renders the default fallback; both served as Nitro route handlers with appropriate CDN cache headers; OG React components in `apps/web/src/server/og-card.tsx` - Add `server/routes/sitemap.xml.ts` (enumerates `/` + all `/m/$slot/$id` URLs from registry index) and `public/robots.txt` pointing at it - Add `apps/web/src/lib/seo.ts` — `buildHead()` helper that emits `title`, `og:*`, `twitter:*`, and `canonical` tags; consumed by `__root.tsx`, `index.tsx`, and `m.$slot.$id.tsx` - Extract `ModuleLogo` into `apps/web/src/components/module-logo.tsx` — shared between slot-cards and the new detail page; remove the duplicate from `slot-cards.tsx` - Add new shadcn-style UI primitives: `command.tsx` (cmdk wrapper), `dialog.tsx`, `input-group.tsx`, `kbd.tsx`, `popover.tsx`, `textarea.tsx` - Add `apps/web/src/server/registry-base.ts` (filesystem reads to avoid SSR loopback-fetch deadlock), `registry-index.ts`, and `module-detail.ts` as the server-side data layer - Add `@vercel/og` and `cmdk` to `apps/web` dependencies; add `vitest run` as `pnpm test` in web package - Mark all corresponding TODO items done --- TODO.md | 30 +- apps/web/package.json | 5 +- apps/web/public/robots.txt | 4 + apps/web/server/routes/og.ts | 19 + apps/web/server/routes/og/[slot]/[id].ts | 39 + apps/web/server/routes/sitemap.xml.ts | 54 + .../web/src/components/builder/slot-cards.tsx | 43 +- .../components/detail/adapter-switcher.tsx | 73 ++ apps/web/src/components/detail/deps-table.tsx | 28 + apps/web/src/components/detail/env-table.tsx | 28 + .../src/components/detail/templates-list.tsx | 87 ++ apps/web/src/components/detail/try-it.tsx | 39 + apps/web/src/components/header.tsx | 14 +- apps/web/src/components/module-logo.tsx | 62 ++ .../web/src/components/search/site-search.tsx | 91 ++ apps/web/src/components/ui/command.tsx | 181 ++++ apps/web/src/components/ui/dialog.tsx | 139 +++ apps/web/src/components/ui/input-group.tsx | 144 +++ apps/web/src/components/ui/kbd.tsx | 26 + apps/web/src/components/ui/popover.tsx | 75 ++ apps/web/src/components/ui/textarea.tsx | 18 + apps/web/src/lib/module-search.ts | 13 + apps/web/src/lib/seo.ts | 58 ++ apps/web/src/routeTree.gen.ts | 24 +- apps/web/src/routes/__root.tsx | 6 +- apps/web/src/routes/index.tsx | 8 + apps/web/src/routes/m.$slot.$id.tsx | 156 +++ apps/web/src/server/builder-state.ts | 38 +- apps/web/src/server/module-detail.ts | 218 +++++ apps/web/src/server/og-card.tsx | 163 +++ apps/web/src/server/registry-base.ts | 46 + apps/web/src/server/registry-index.ts | 16 + apps/web/vite.config.ts | 11 + pnpm-lock.yaml | 926 ++++++++++++++++++ 34 files changed, 2782 insertions(+), 100 deletions(-) create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/server/routes/og.ts create mode 100644 apps/web/server/routes/og/[slot]/[id].ts create mode 100644 apps/web/server/routes/sitemap.xml.ts create mode 100644 apps/web/src/components/detail/adapter-switcher.tsx create mode 100644 apps/web/src/components/detail/deps-table.tsx create mode 100644 apps/web/src/components/detail/env-table.tsx create mode 100644 apps/web/src/components/detail/templates-list.tsx create mode 100644 apps/web/src/components/detail/try-it.tsx create mode 100644 apps/web/src/components/module-logo.tsx create mode 100644 apps/web/src/components/search/site-search.tsx create mode 100644 apps/web/src/components/ui/command.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/input-group.tsx create mode 100644 apps/web/src/components/ui/kbd.tsx create mode 100644 apps/web/src/components/ui/popover.tsx create mode 100644 apps/web/src/components/ui/textarea.tsx create mode 100644 apps/web/src/lib/module-search.ts create mode 100644 apps/web/src/lib/seo.ts create mode 100644 apps/web/src/routes/m.$slot.$id.tsx create mode 100644 apps/web/src/server/module-detail.ts create mode 100644 apps/web/src/server/og-card.tsx create mode 100644 apps/web/src/server/registry-base.ts create mode 100644 apps/web/src/server/registry-index.ts diff --git a/TODO.md b/TODO.md index 9f57bb7..c32ccc2 100644 --- a/TODO.md +++ b/TODO.md @@ -13,21 +13,21 @@ State at end of last session: pre-1.0 architectural cleanup landed. The builder is functionally wired but visually unfinished. It's inline-styled radios + a code block. -- [ ] Add Tailwind 4 to `apps/web` — use the `@tailwindcss/vite` plugin (mirror what the `styling-tailwind` module emits for the `tanstack-start` adapter) -- [ ] Hand-roll a small component layer or pull in shadcn (web variant) for cards, buttons, inputs, code blocks -- [ ] Replace the radio-list `Builder` with a card grid per slot, showing module label + description + homepage link -- [ ] Show _why_ a module is filtered out (e.g. "needs `framework: next`, you picked tanstack-start") instead of just dropping it -- [ ] Deep linking: encode selections in URL search params (`?framework=next&orm=drizzle&...`) so users can share configurations -- [ ] Copy-to-clipboard for the generated `pnpm create stanza ...` command -- [ ] Preview pane: list of files stanza will write (derived from `module.adapters[].templates[].dest` for the selected adapter combo) -- [ ] Show pinned npm versions per selected module (from `adapter.dependencies` / `adapter.devDependencies`) -- [ ] Module detail route at `/m/$slot/$id` — full description, all adapters, deps, env vars, templates list -- [ ] Search route at `/search` backed by the same registry index -- [ ] Layout: header (logo, GitHub link, docs link), footer -- [ ] Dark mode (Tailwind 4 `light-dark()` + system pref) -- [ ] SEO: meta tags via `head()` on routes, OG image, sitemap -- [ ] Host the registry on the same domain — wire the registry-build output (`scripts/registry-build.ts` → `dist/registry/`) to `apps/web/public/registry/` and serve statically, or via a Vercel route handler -- [ ] Vercel deploy config — `vercel.json` if needed, env vars for `STANZA_REGISTRY` (point dev at local FS, prod at the deployed CDN path) +- [x] Add Tailwind 4 to `apps/web` — `@tailwindcss/vite` v4 wired in `vite.config.ts`, theme variables in `styles.css` via `@theme` (OKLCH), dark mode via `.dark` class +- [x] Hand-roll a small component layer or pull in shadcn (web variant) — shadcn-style primitives at `apps/web/src/components/ui/` (Card, Button, Input, Badge, Tooltip, Tabs, DropdownMenu, Separator, Sonner). All use base-ui under the hood +- [x] Replace the radio-list `Builder` with a card grid per slot — `apps/web/src/components/builder/slot-cards.tsx` +- [ ] Show _why_ a module is filtered out (e.g. "needs `framework: next`, you picked tanstack-start") instead of just dropping it — partly covered by the tooltip in `slot-cards.tsx`, could expand to inline why-disabled copy on the card itself +- [x] Deep linking: encode selections in URL search params (`?framework=next&orm=drizzle&...`) — typed `validateSearch` on `routes/index.tsx`, parser in `lib/selection.ts` +- [x] Copy-to-clipboard for the generated `pnpm create stanza ...` command — `command-bar.tsx` +- [x] Preview pane: list of files stanza will write — `file-preview.tsx` with `@pierre/trees` + Shiki-rendered server-side preview +- [x] Show pinned npm versions per selected module — `apps/web/src/components/detail/deps-table.tsx` on `/m/$slot/$id` (also covers devDeps + scripts + env). Builder cards stay clean per the design call; versions surface on the detail page +- [x] Module detail route at `/m/$slot/$id` — full description, adapter switcher (peer chip rows), deps tables, env table, templates list with click-to-expand Shiki preview, "Try it" command. `apps/web/src/routes/m.$slot.$id.tsx` +- [x] Search route — implemented as a header-only popover (`apps/web/src/components/search/site-search.tsx`) bound to ⌘K, mirrors the CLI's id/label/description/slot match (`lib/module-search.ts`) +- [x] Layout: header (logo, GitHub link, search), footer — `apps/web/src/components/header.tsx`, `footer.tsx` +- [x] Dark mode — Tailwind 4 `.dark` variant + ThemeProvider, system pref aware +- [x] SEO: meta tags via `head()` on routes, OG image, sitemap — `apps/web/src/lib/seo.ts` builds `head()` output with title/og:\*/twitter:\*/canonical for every route. Dynamic OG via `@vercel/og` at `/og/$slot/$id` and `/og` (Nitro server routes under `apps/web/server/routes/`). `sitemap.xml` server route enumerates the index; `public/robots.txt` is static +- [x] Host the registry on the same domain — `prebuild` script copies `dist/registry/` into `apps/web/public/registry/`. CLI's `DEFAULT_REGISTRY_URL` points at the same path. Server-side reads use the filesystem directly (`apps/web/src/server/registry-base.ts`) to avoid the SSR loopback-fetch deadlock +- [ ] Vercel deploy config — Vercel auto-detects TanStack Start's `.output/` directory, no `vercel.json` needed. Env vars (`STANZA_REGISTRY`) optional override - [ ] Docs section (could be MDX routes): overview, authoring guide, registry spec ## CLI (apps/cli) diff --git a/apps/web/package.json b/apps/web/package.json index 3169bcd..35b80e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "build": "vite build", "prebuild": "./scripts/copy-registry.sh", "start": "node .output/server/index.mjs", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.5.0", @@ -25,8 +26,10 @@ "@tanstack/react-router-ssr-query": "^1.167.0", "@tanstack/react-start": "^1.168.7", "@tanstack/router-plugin": "^1.168.7", + "@vercel/og": "^0.11.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "nitro": "^3.0.260429-beta", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..e7e89d4 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://stanza.tools/sitemap.xml diff --git a/apps/web/server/routes/og.ts b/apps/web/server/routes/og.ts new file mode 100644 index 0000000..b924b0a --- /dev/null +++ b/apps/web/server/routes/og.ts @@ -0,0 +1,19 @@ +import { ImageResponse } from "@vercel/og"; +import { defineHandler } from "nitro/h3"; + +import { OgDefault } from "@/server/og-card"; + +/** + * `/og` — default OG used by `/`, `/search`, and anywhere without a more + * specific image. Static-ish content; the SWR header lets the CDN serve it + * stale for a long time. + */ +export default defineHandler(() => { + return new ImageResponse(OgDefault(), { + width: 1200, + height: 630, + headers: { + "cache-control": "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800", + }, + }); +}); diff --git a/apps/web/server/routes/og/[slot]/[id].ts b/apps/web/server/routes/og/[slot]/[id].ts new file mode 100644 index 0000000..42dba54 --- /dev/null +++ b/apps/web/server/routes/og/[slot]/[id].ts @@ -0,0 +1,39 @@ +import type { RegistryIndex } from "@stanza/registry"; +import { ImageResponse } from "@vercel/og"; +import { defineHandler, getRouterParams } from "nitro/h3"; + +import { OgCard } from "@/server/og-card"; +import { loadRegistryFile } from "@/server/registry-base"; + +/** + * `/og/$slot/$id` — per-module OG card. Dynamically rendered at request time + * via Satori (bundled inside `@vercel/og`). Looks the module up in the + * registry index and bails 404 if it's not there. + */ +export default defineHandler(async (event) => { + const params = getRouterParams(event); + const slot = String(params.slot ?? ""); + const id = String(params.id ?? "").replace(/\.png$/, ""); + if (!slot || !id) { + return new Response("Not found", { status: 404 }); + } + + let index: RegistryIndex; + try { + index = await loadRegistryFile("index.json"); + } catch { + return new Response("Registry unavailable", { status: 502 }); + } + const summary = index.modules.find((m) => m.slot === slot && m.id === id); + if (!summary) { + return new Response("Not found", { status: 404 }); + } + + return new ImageResponse(OgCard({ summary }), { + width: 1200, + height: 630, + headers: { + "cache-control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + }, + }); +}); diff --git a/apps/web/server/routes/sitemap.xml.ts b/apps/web/server/routes/sitemap.xml.ts new file mode 100644 index 0000000..ddca1d2 --- /dev/null +++ b/apps/web/server/routes/sitemap.xml.ts @@ -0,0 +1,54 @@ +import type { RegistryIndex } from "@stanza/registry"; +import type { H3Event } from "nitro/h3"; +import { defineHandler, getRequestHost, getRequestProtocol } from "nitro/h3"; + +import { loadRegistryFile } from "@/server/registry-base"; + +/** + * `/sitemap.xml` — enumerates the home plus every module detail page. Search + * params (peer overrides) are intentionally excluded: the canonical URL for a + * module is the bare `/m/$slot/$id`, and the page renders an auto-default + * adapter from there. + */ +export default defineHandler(async (event) => { + const origin = originFromEvent(event); + let index: RegistryIndex; + try { + index = await loadRegistryFile("index.json"); + } catch { + return new Response("Registry unavailable", { status: 502 }); + } + + const urls = [ + { loc: `${origin}/`, changefreq: "weekly", priority: "1.0" }, + ...index.modules.map((m) => ({ + loc: `${origin}/m/${m.slot}/${m.id}`, + changefreq: "monthly", + priority: "0.7", + })), + ]; + + const body = ` + +${urls + .map( + (u) => + ` ${u.loc}${u.changefreq}${u.priority}`, + ) + .join("\n")} + +`; + + return new Response(body, { + headers: { + "content-type": "application/xml; charset=utf-8", + "cache-control": "public, max-age=3600, s-maxage=86400", + }, + }); +}); + +function originFromEvent(event: H3Event): string { + const host = getRequestHost(event); + const proto = getRequestProtocol(event); + return `${proto}://${host}`; +} diff --git a/apps/web/src/components/builder/slot-cards.tsx b/apps/web/src/components/builder/slot-cards.tsx index e4b1146..9563c7e 100644 --- a/apps/web/src/components/builder/slot-cards.tsx +++ b/apps/web/src/components/builder/slot-cards.tsx @@ -1,7 +1,8 @@ -import type { Logo, Module, ModuleSummary, SlotId } from "@stanza/registry"; +import type { Module, ModuleSummary, SlotId } from "@stanza/registry"; import { KNOWN_SLOTS, emptyManifest, resolveAdapter } from "@stanza/registry"; import { IconCheck } from "@tabler/icons-react"; +import { ModuleLogo } from "@/components/module-logo"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { Selections } from "@/lib/selection"; import { cn } from "@/lib/utils"; @@ -158,46 +159,6 @@ function ModuleCard({ ); } -function ModuleLogo({ logo, label }: { logo: Logo | undefined; label: string }) { - if (!logo) { - // Fallback: a subtle initial-letter tile. Keeps card geometry consistent. - return ( -
- {label.slice(0, 1)} -
- ); - } - const wrapClass = - "flex size-9 shrink-0 items-center justify-center [&_svg]:size-7 [&_svg]:max-h-7 [&_svg]:max-w-7"; - if (typeof logo === "string") { - return ( -
- ); - } - return ( - <> -
-
- - ); -} - function describeError(kind: string): string { switch (kind) { case "missing-peer": diff --git a/apps/web/src/components/detail/adapter-switcher.tsx b/apps/web/src/components/detail/adapter-switcher.tsx new file mode 100644 index 0000000..3698815 --- /dev/null +++ b/apps/web/src/components/detail/adapter-switcher.tsx @@ -0,0 +1,73 @@ +import type { ModuleSummary, RegistryIndex, SlotId } from "@stanza/registry"; +import { slotLabel } from "@stanza/registry"; + +import { cn } from "@/lib/utils"; + +/** + * Chip-row picker for each peer slot the detail page can switch on. Hides any + * slot with fewer than 2 options — there's nothing to pick. The active value + * is read from `resolvedPeers` so the UI reflects auto-defaults too. + */ +export function AdapterSwitcher({ + index, + peerOptions, + resolvedPeers, + onChange, +}: { + index: RegistryIndex; + peerOptions: Partial>; + resolvedPeers: Partial>; + onChange: (slot: SlotId, id: string) => void; +}) { + const switchable = (Object.entries(peerOptions) as [SlotId, string[]][]).filter( + ([, opts]) => opts.length > 1, + ); + if (switchable.length === 0) return null; + + return ( +
+ {switchable.map(([slot, options]) => { + const active = resolvedPeers[slot]; + return ( +
+ + {slotLabel(slot)} + +
+ {options.map((id) => { + const summary = index.modules.find((m) => m.slot === slot && m.id === id); + const label = summary?.label ?? id; + const isActive = active === id; + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} + +export function summaryFor( + index: RegistryIndex, + slot: SlotId, + id: string, +): ModuleSummary | undefined { + return index.modules.find((m) => m.slot === slot && m.id === id); +} diff --git a/apps/web/src/components/detail/deps-table.tsx b/apps/web/src/components/detail/deps-table.tsx new file mode 100644 index 0000000..c39d83a --- /dev/null +++ b/apps/web/src/components/detail/deps-table.tsx @@ -0,0 +1,28 @@ +/** + * Renders a deps map as a key/value list. Used for `dependencies`, + * `devDependencies`, and `scripts`. Returns `null` when the map is empty so + * the page can simply drop the section. + */ +export function DepsTable({ title, entries }: { title: string; entries: Record }) { + const items = Object.entries(entries); + if (items.length === 0) return null; + + return ( +
+

+ {title} +

+
    + {items.map(([name, version]) => ( +
  • + {name} + {version} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/components/detail/env-table.tsx b/apps/web/src/components/detail/env-table.tsx new file mode 100644 index 0000000..d5cebf8 --- /dev/null +++ b/apps/web/src/components/detail/env-table.tsx @@ -0,0 +1,28 @@ +import type { EnvVar } from "@stanza/registry"; + +import { Badge } from "@/components/ui/badge"; + +export function EnvTable({ env }: { env: EnvVar[] }) { + if (env.length === 0) return null; + return ( +
+

+ Environment variables +

+
    + {env.map((e) => ( +
  • +
    + {e.name} + + {e.required ? "required" : "optional"} + + = {e.example} +
    + {e.description &&

    {e.description}

    } +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/components/detail/templates-list.tsx b/apps/web/src/components/detail/templates-list.tsx new file mode 100644 index 0000000..70f0b2a --- /dev/null +++ b/apps/web/src/components/detail/templates-list.tsx @@ -0,0 +1,87 @@ +import type { TemplateRef } from "@stanza/registry"; +import { useMemo, useState } from "react"; + +import { useTheme } from "@/components/theme-provider"; +import type { Preview } from "@/server/highlighter"; + +/** + * Lists the templates an adapter ships, with click-to-expand Shiki previews. + * Previews are pre-rendered server-side and keyed by `dest`. + */ +export function TemplatesList({ + templates, + previews, +}: { + templates: TemplateRef[]; + previews: Record; +}) { + if (templates.length === 0) return null; + return ( +
+

+ Templates +

+
    + {templates.map((tpl) => ( + + ))} +
+
+ ); +} + +function TemplateRow({ + template, + preview, +}: { + template: TemplateRef; + preview: Preview | undefined; +}) { + const [open, setOpen] = useState(false); + const hasPreview = Boolean(preview); + return ( +
  • + + {open && preview && } +
  • + ); +} + +function PreviewBlock({ preview }: { preview: Preview }) { + const { theme } = useTheme(); + const resolved = useResolvedTheme(theme); + const html = useMemo( + () => (resolved === "dark" ? preview.dark : preview.light), + [preview, resolved], + ); + return ( +
    + ); +} + +function scopeLabel(scope: TemplateRef["scope"]): string { + if (scope === "repo") return "repo"; + if (scope === "package") return "package"; + return "app"; +} + +function useResolvedTheme(theme: string): "light" | "dark" { + if (theme === "dark") return "dark"; + if (theme === "light") return "light"; + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} diff --git a/apps/web/src/components/detail/try-it.tsx b/apps/web/src/components/detail/try-it.tsx new file mode 100644 index 0000000..f8ece2f --- /dev/null +++ b/apps/web/src/components/detail/try-it.tsx @@ -0,0 +1,39 @@ +import { IconCopy } from "@tabler/icons-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export function TryIt({ command }: { command: string }) { + const onCopy = async () => { + try { + await navigator.clipboard.writeText(command); + toast.success("Copied to clipboard"); + } catch { + toast.error("Couldn't access the clipboard"); + } + }; + + return ( +
    +

    + Try it +

    +
    +
    +          {command}
    +        
    + + + + + } + /> + Copy + +
    +
    + ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index d7c514f..09aaabd 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,17 +1,23 @@ import { IconBrandGithub } from "@tabler/icons-react"; -import { Link } from "@tanstack/react-router"; +import { Link, useLoaderData } from "@tanstack/react-router"; import { ModeToggle } from "@/components/mode-toggle"; +import { SiteSearch } from "@/components/search/site-search"; import { Button } from "@/components/ui/button"; export function Header() { + // Root loader stocks the registry index. `useLoaderData` reads it via the + // typed root-match path so the search popover has data without re-fetching. + const index = useLoaderData({ from: "__root__" }); + return (
    -
    +
    stanza - +
    ); diff --git a/apps/web/src/components/module-logo.tsx b/apps/web/src/components/module-logo.tsx new file mode 100644 index 0000000..017e8b6 --- /dev/null +++ b/apps/web/src/components/module-logo.tsx @@ -0,0 +1,62 @@ +import type { Logo } from "@stanza/registry"; + +import { cn } from "@/lib/utils"; + +/** + * Renders an inline-SVG module logo, falling back to a single-letter tile. + * Used by the slot cards, header search results, and the module detail page. + * Logos come from our trusted first-party registry payload. + */ +export function ModuleLogo({ + logo, + label, + size = "md", +}: { + logo: Logo | undefined; + label: string; + size?: "sm" | "md" | "lg"; +}) { + const sizeClass = + size === "lg" + ? "size-12 [&_svg]:size-9 [&_svg]:max-h-9 [&_svg]:max-w-9" + : size === "sm" + ? "size-6 [&_svg]:size-4 [&_svg]:max-h-4 [&_svg]:max-w-4" + : "size-9 [&_svg]:size-7 [&_svg]:max-h-7 [&_svg]:max-w-7"; + const textSize = size === "lg" ? "text-base" : size === "sm" ? "text-[10px]" : "text-xs"; + + if (!logo) { + return ( +
    + {label.slice(0, 1)} +
    + ); + } + + const wrapClass = cn("flex shrink-0 items-center justify-center", sizeClass); + + if (typeof logo === "string") { + return
    ; + } + + return ( + <> +
    +
    + + ); +} diff --git a/apps/web/src/components/search/site-search.tsx b/apps/web/src/components/search/site-search.tsx new file mode 100644 index 0000000..be292fc --- /dev/null +++ b/apps/web/src/components/search/site-search.tsx @@ -0,0 +1,91 @@ +import type { ModuleSummary, RegistryIndex, SlotId } from "@stanza/registry"; +import { slotLabel } from "@stanza/registry"; +import { IconSearch } from "@tabler/icons-react"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; + +import { ModuleLogo } from "@/components/module-logo"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { groupBySlot } from "@/lib/module-search"; +import { cn } from "@/lib/utils"; + +/** + * Global module search. Triggered by a search-input-looking button in the + * header and by `⌘K` / `Ctrl-K`. cmdk filters the registry index client-side, + * groups by slot, and navigates to `/m/$slot/$id` on selection. + */ +export function SiteSearch({ index }: { index: RegistryIndex }) { + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const groups = useMemo(() => groupBySlot(index.modules), [index.modules]); + + function go(summary: ModuleSummary) { + setOpen(false); + void navigate({ to: "/m/$slot/$id", params: { slot: summary.slot, id: summary.id } }); + } + + return ( + <> + + + + + No matches. + {groups.map(({ slot, modules }) => ( + + {modules.map((m) => ( + go(m)} + > + +
    +
    {m.label}
    +
    + {m.description} +
    +
    + + {m.slot}/{m.id} + +
    + ))} +
    + ))} +
    +
    + + ); +} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx new file mode 100644 index 0000000..140303d --- /dev/null +++ b/apps/web/src/components/ui/command.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { IconSearch, IconCheck } from "@tabler/icons-react"; +import { Command as CommandPrimitive } from "cmdk"; +import * as React from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"; +import { cn } from "@/lib/utils"; + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = false, + ...props +}: Omit, "children"> & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; + children: React.ReactNode; +}) { + return ( + + + {title} + {description} + + + {children} + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
    + + + + + + +
    + ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + ); +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3803ecf --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { IconX } from "@tabler/icons-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return ; +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return ; +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ; +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + } + > + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
    + {children} + {showCloseButton && ( + }>Close + )} +
    + ); +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx new file mode 100644 index 0000000..ea3024d --- /dev/null +++ b/apps/web/src/components/ui/input-group.tsx @@ -0,0 +1,144 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    [data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className, + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-xs font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-none [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    { + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva("flex items-center gap-2 text-xs shadow-none", { + variants: { + size: { + xs: "h-6 gap-1 rounded-none px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "gap-1", + "icon-xs": "size-6 rounded-none p-0 has-[>svg]:p-0", + "icon-sm": "size-7 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, +}); + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset"; + }) { + return ( +