| 1 | | − | import { Box, Lock, Package, Search } from "lucide-react"; |
| 1 | + | import { ArrowUpRight, Box, Check, ChevronDown, Download, Lock, Search } from "lucide-react"; |
| 2 | 2 | | import { Form, Link, data } from "react-router"; |
| 3 | 3 | | |
| 4 | 4 | | import { ECOSYSTEMS, type Ecosystem, type PackageSummary } from "@g1t/contracts"; |
| 5 | 5 | | |
| 6 | 6 | | import type { Route } from "./+types/packages"; |
| 7 | + | import { PackageIcon } from "../../components/package-icon"; |
| 7 | 8 | | import { CopyLine, EmptyState, TimeAgo } from "../../components/ui"; |
| 8 | 9 | | import { Badge } from "../../components/ui/badge"; |
| 10 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; |
| 9 | 11 | | import { cn } from "../../lib/cn"; |
| 10 | 12 | | import { page } from "../../lib/meta"; |
| 11 | | − | import { ECOSYSTEM_LABEL, formatBytes } from "../../lib/packages"; |
| 13 | + | import { |
| 14 | + | ECOSYSTEM_LABEL, |
| 15 | + | type PackageSort, |
| 16 | + | REGISTRIES, |
| 17 | + | SORT_LABEL, |
| 18 | + | VISIBILITY_LABEL, |
| 19 | + | type VisibilityFilter, |
| 20 | + | arrange, |
| 21 | + | formatBytes, |
| 22 | + | shortCount, |
| 23 | + | } from "../../lib/packages"; |
| 12 | 24 | | import { packages } from "../../lib/services.server"; |
| 13 | 25 | | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; |
| 14 | 26 | | |
| 27 | + | const DOCS = "https://docs.g1t.sh"; |
| 28 | + | |
| 15 | 29 | | export function meta({ params, ...args }: Route.MetaArgs) { |
| 16 | 30 | | return page(args, { title: `Packages · ${params.owner} · g1t` }); |
| 17 | 31 | | } |
| 18 | 32 | | |
| 33 | + | function pick<T extends string>(value: string | null, options: readonly T[], fallback: T): T { |
| 34 | + | return value && (options as readonly string[]).includes(value) ? (value as T) : fallback; |
| 35 | + | } |
| 36 | + | |
| 19 | 37 | | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 20 | 38 | | const viewer = getViewer(context); |
| 21 | 39 | | // Members only for now; a workspace's public packages are found through |
| 22 | 40 | | // their repositories and search. |
| 23 | 41 | | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 24 | 42 | | const url = new URL(request.url); |
| 25 | | − | const asked = url.searchParams.get("type"); |
| 26 | | − | const ecosystem = asked && (ECOSYSTEMS as readonly string[]).includes(asked) ? (asked as Ecosystem) : null; |
| 43 | + | const type = url.searchParams.get("type"); |
| 44 | + | const ecosystem = type && (ECOSYSTEMS as readonly string[]).includes(type) ? (type as Ecosystem) : null; |
| 27 | 45 | | const query = url.searchParams.get("q")?.trim() || null; |
| 28 | | − | const list = unwrap(await packages.list(params.owner, viewer, { ecosystem, query })); |
| 29 | | − | return { list, ecosystem, query, workspace: params.owner.toLowerCase(), username: viewer?.username ?? "you" }; |
| 46 | + | const visibility = pick<VisibilityFilter>(url.searchParams.get("visibility"), ["all", "public", "private"], "all"); |
| 47 | + | const sort = pick<PackageSort>(url.searchParams.get("sort"), ["updated", "downloads", "name"], "updated"); |
| 48 | + | const found = unwrap(await packages.list(params.owner, viewer, { ecosystem, query })); |
| 49 | + | // Whether the workspace has any at all decides between a filtered-out |
| 50 | + | // list and the first-package page. |
| 51 | + | const any = found.length > 0 || ecosystem != null || query != null; |
| 52 | + | return { |
| 53 | + | list: arrange(found, visibility, sort), |
| 54 | + | any, |
| 55 | + | ecosystem, |
| 56 | + | query, |
| 57 | + | visibility, |
| 58 | + | sort, |
| 59 | + | workspace: params.owner.toLowerCase(), |
| 60 | + | }; |
| 30 | 61 | | } |
| 31 | 62 | | |
| 32 | | − | /** The packages a workspace publishes, by registry, with how to start one. */ |
| 63 | + | type Filters = { type: string | null; q: string | null; visibility: VisibilityFilter; sort: PackageSort }; |
| 64 | + | |
| 65 | + | /** The page's address with one filter changed, defaults left out. */ |
| 66 | + | function hrefWith(workspace: string, current: Filters, change: Partial<Filters>): string { |
| 67 | + | const next = { ...current, ...change }; |
| 68 | + | const params = new URLSearchParams(); |
| 69 | + | if (next.type) params.set("type", next.type); |
| 70 | + | if (next.q) params.set("q", next.q); |
| 71 | + | if (next.visibility !== "all") params.set("visibility", next.visibility); |
| 72 | + | if (next.sort !== "updated") params.set("sort", next.sort); |
| 73 | + | return `/${workspace}/-/packages${params.size ? `?${params}` : ""}`; |
| 74 | + | } |
| 75 | + | |
| 76 | + | /** The packages a workspace publishes: filtered, sorted, or, with none yet, how to start. */ |
| 33 | 77 | | export default function Packages({ loaderData }: Route.ComponentProps) { |
| 34 | | − | const { list, ecosystem, query, workspace, username } = loaderData; |
| 35 | | − | const filtered = ecosystem != null || query != null; |
| 78 | + | const { list, any, ecosystem, query, visibility, sort, workspace } = loaderData; |
| 79 | + | if (!any) return <ChooseRegistry workspace={workspace} />; |
| 80 | + | const filters: Filters = { type: ecosystem, q: query, visibility, sort }; |
| 81 | + | const filtered = ecosystem != null || query != null || visibility !== "all"; |
| 36 | 82 | | return ( |
| 37 | | − | <div className="space-y-6"> |
| 38 | | − | <div className="flex justify-end"> |
| 39 | | − | <a href="https://docs.g1t.sh/guides/packages/" className="text-sm text-muted hover:text-fg"> |
| 40 | | − | How packages work |
| 41 | | − | </a> |
| 42 | | − | </div> |
| 43 | | − | |
| 44 | | − | <Form method="get" className="flex flex-wrap items-center gap-2"> |
| 45 | | − | <label className="relative min-w-0 grow"> |
| 46 | | − | <span className="sr-only">Search packages</span> |
| 83 | + | <div className="space-y-4"> |
| 84 | + | <div className="flex flex-wrap items-center gap-2"> |
| 85 | + | <FilterMenu |
| 86 | + | label="Type" |
| 87 | + | value={ecosystem ? ECOSYSTEM_LABEL[ecosystem] : "All"} |
| 88 | + | options={[null, ...ECOSYSTEMS].map((option) => ({ |
| 89 | + | label: option ? ECOSYSTEM_LABEL[option] : "All", |
| 90 | + | to: hrefWith(workspace, filters, { type: option }), |
| 91 | + | current: ecosystem === option, |
| 92 | + | }))} |
| 93 | + | /> |
| 94 | + | <Form method="get" className="relative min-w-48 grow"> |
| 95 | + | <label className="sr-only" htmlFor="package-search"> |
| 96 | + | Search packages |
| 97 | + | </label> |
| 47 | 98 | | <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> |
| 48 | 99 | | <input |
| 100 | + | id="package-search" |
| 49 | 101 | | name="q" |
| 50 | 102 | | defaultValue={query ?? ""} |
| 51 | | − | placeholder="Find a package" |
| 103 | + | placeholder="Search packages" |
| 52 | 104 | | className="h-9 w-full rounded-lg border border-line bg-surface pr-3 pl-8 text-sm placeholder:text-faint focus:border-line-strong focus:outline-none" |
| 53 | 105 | | /> |
| 54 | | − | </label> |
| 55 | | − | {ecosystem && <input type="hidden" name="type" value={ecosystem} />} |
| 56 | | − | <nav aria-label="Registry" className="flex flex-wrap gap-1 text-sm"> |
| 57 | | − | {[null, ...ECOSYSTEMS].map((option) => { |
| 58 | | − | const params = new URLSearchParams(); |
| 59 | | − | if (option) params.set("type", option); |
| 60 | | − | if (query) params.set("q", query); |
| 61 | | − | const href = `/${workspace}/-/packages${params.size ? `?${params}` : ""}`; |
| 62 | | − | return ( |
| 63 | | − | <Link |
| 64 | | − | key={option ?? "all"} |
| 65 | | − | to={href} |
| 66 | | − | aria-current={ecosystem === option ? "page" : undefined} |
| 67 | | − | className={cn( |
| 68 | | − | "rounded-md px-2.5 py-1.5 transition-colors", |
| 69 | | − | ecosystem === option ? "bg-raised text-fg" : "text-muted hover:text-fg", |
| 70 | | − | )} |
| 71 | | − | > |
| 72 | | − | {option ? ECOSYSTEM_LABEL[option] : "All"} |
| 106 | + | {ecosystem && <input type="hidden" name="type" value={ecosystem} />} |
| 107 | + | {visibility !== "all" && <input type="hidden" name="visibility" value={visibility} />} |
| 108 | + | {sort !== "updated" && <input type="hidden" name="sort" value={sort} />} |
| 109 | + | </Form> |
| 110 | + | <FilterMenu |
| 111 | + | label="Visibility" |
| 112 | + | value={VISIBILITY_LABEL[visibility]} |
| 113 | + | options={(["all", "public", "private"] as const).map((option) => ({ |
| 114 | + | label: VISIBILITY_LABEL[option], |
| 115 | + | to: hrefWith(workspace, filters, { visibility: option }), |
| 116 | + | current: visibility === option, |
| 117 | + | }))} |
| 118 | + | /> |
| 119 | + | <FilterMenu |
| 120 | + | label="Sort" |
| 121 | + | value={SORT_LABEL[sort]} |
| 122 | + | options={(["updated", "downloads", "name"] as const).map((option) => ({ |
| 123 | + | label: SORT_LABEL[option], |
| 124 | + | to: hrefWith(workspace, filters, { sort: option }), |
| 125 | + | current: sort === option, |
| 126 | + | }))} |
| 127 | + | /> |
| 128 | + | </div> |
| 129 | + | |
| 130 | + | <section className="overflow-hidden rounded-xl border border-line bg-surface"> |
| 131 | + | <header className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5 text-sm"> |
| 132 | + | <span className="font-medium"> |
| 133 | + | {list.length} {list.length === 1 ? "package" : "packages"} |
| 134 | + | {filtered && ( |
| 135 | + | <Link to={`/${workspace}/-/packages`} className="ml-3 text-xs font-normal text-muted hover:text-fg"> |
| 136 | + | Clear filters |
| 73 | 137 | | </Link> |
| 74 | | − | ); |
| 75 | | − | })} |
| 76 | | − | </nav> |
| 77 | | − | </Form> |
| 78 | | − | |
| 79 | | − | {list.length === 0 ? ( |
| 80 | | − | filtered ? ( |
| 81 | | − | <EmptyState title="No packages match">Try another registry, or clear the search.</EmptyState> |
| 138 | + | )} |
| 139 | + | </span> |
| 140 | + | <a href={`${DOCS}/guides/packages/`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> |
| 141 | + | How packages work <ArrowUpRight size={12} /> |
| 142 | + | </a> |
| 143 | + | </header> |
| 144 | + | {list.length === 0 ? ( |
| 145 | + | <div className="p-4"> |
| 146 | + | <EmptyState title="No packages match">Try another type or visibility, or clear the search.</EmptyState> |
| 147 | + | </div> |
| 82 | 148 | | ) : ( |
| 83 | | − | <FirstPackage workspace={workspace} username={username} /> |
| 84 | | − | ) |
| 85 | | − | ) : ( |
| 86 | | − | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 87 | | − | {list.map((pkg) => ( |
| 88 | | − | <PackageRow key={pkg.id} pkg={pkg} /> |
| 89 | | − | ))} |
| 90 | | − | </ul> |
| 91 | | − | )} |
| 149 | + | <ul className="divide-y divide-line"> |
| 150 | + | {list.map((pkg) => ( |
| 151 | + | <PackageRow key={pkg.id} pkg={pkg} /> |
| 152 | + | ))} |
| 153 | + | </ul> |
| 154 | + | )} |
| 155 | + | </section> |
| 92 | 156 | | </div> |
| 93 | 157 | | ); |
| 94 | 158 | | } |
| 95 | 159 | | |
| 160 | + | /** A filter as a button that opens its choices; each choice is a link, so the address says the view. */ |
| 161 | + | function FilterMenu({ label, value, options }: { label: string; value: string; options: { label: string; to: string; current: boolean }[] }) { |
| 162 | + | return ( |
| 163 | + | <DropdownMenu> |
| 164 | + | <DropdownMenuTrigger className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong data-[state=open]:border-line-strong"> |
| 165 | + | <span className="text-muted">{label}:</span> |
| 166 | + | <span className="font-medium">{value}</span> |
| 167 | + | <ChevronDown size={13} className="text-faint" /> |
| 168 | + | </DropdownMenuTrigger> |
| 169 | + | <DropdownMenuContent align="start" className="min-w-44"> |
| 170 | + | {options.map((option) => ( |
| 171 | + | <DropdownMenuItem key={option.label} asChild> |
| 172 | + | <Link to={option.to} preventScrollReset> |
| 173 | + | <Check size={14} className={cn(!option.current && "invisible")} /> |
| 174 | + | {option.label} |
| 175 | + | </Link> |
| 176 | + | </DropdownMenuItem> |
| 177 | + | ))} |
| 178 | + | </DropdownMenuContent> |
| 179 | + | </DropdownMenu> |
| 180 | + | ); |
| 181 | + | } |
| 182 | + | |
| 96 | 183 | | function PackageRow({ pkg }: { pkg: PackageSummary }) { |
| 97 | 184 | | return ( |
| 98 | 185 | | <li> |
| 99 | 186 | | <Link |
| 100 | 187 | | to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`} |
| 101 | | − | className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 transition-colors hover:bg-raised/50" |
| 188 | + | className="flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-raised/50" |
| 102 | 189 | | > |
| 103 | | − | <span className="text-faint">{pkg.visibility === "private" ? <Lock size={15} /> : <Package size={15} />}</span> |
| 190 | + | <PackageIcon ecosystem={pkg.ecosystem} /> |
| 104 | 191 | | <span className="min-w-0 grow"> |
| 105 | 192 | | <span className="flex flex-wrap items-center gap-2"> |
| 106 | 193 | | <span className="truncate font-medium">{pkg.name}</span> |
| 107 | | − | <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge> |
| 108 | | − | {pkg.visibility === "private" && <Badge>Private</Badge>} |
| 194 | + | {pkg.visibility === "private" && ( |
| 195 | + | <Badge> |
| 196 | + | <Lock size={10} /> |
| 197 | + | Private |
| 198 | + | </Badge> |
| 199 | + | )} |
| 109 | 200 | | </span> |
| 110 | | − | <span className="mt-0.5 block truncate font-mono text-xs text-faint">{pkg.address}</span> |
| 111 | | − | </span> |
| 112 | | − | <span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted tabular-nums"> |
| 113 | | − | {pkg.repo && ( |
| 114 | | − | <span className="inline-flex items-center gap-1"> |
| 115 | | − | <Box size={12} /> |
| 116 | | − | {pkg.repo.name} |
| 201 | + | <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted"> |
| 202 | + | <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span> |
| 203 | + | <span className="text-faint">·</span> |
| 204 | + | <span> |
| 205 | + | Updated <TimeAgo at={pkg.updated_at} /> |
| 117 | 206 | | </span> |
| 118 | | − | )} |
| 119 | | − | {pkg.latest && <span className="font-mono">{pkg.latest}</span>} |
| 120 | | − | <span> |
| 121 | | − | {pkg.versions} {pkg.versions === 1 ? "version" : "versions"} |
| 207 | + | {pkg.repo && ( |
| 208 | + | <> |
| 209 | + | <span className="text-faint">·</span> |
| 210 | + | <span className="inline-flex items-center gap-1"> |
| 211 | + | <Box size={11} /> |
| 212 | + | {pkg.repo.name} |
| 213 | + | </span> |
| 214 | + | </> |
| 215 | + | )} |
| 216 | + | {pkg.description && ( |
| 217 | + | <> |
| 218 | + | <span className="text-faint">·</span> |
| 219 | + | <span className="max-w-md truncate">{pkg.description}</span> |
| 220 | + | </> |
| 221 | + | )} |
| 122 | 222 | | </span> |
| 123 | | − | <span>{formatBytes(pkg.size)}</span> |
| 124 | | − | <span> |
| 125 | | − | Updated <TimeAgo at={pkg.updated_at} /> |
| 223 | + | </span> |
| 224 | + | <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex"> |
| 225 | + | {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>} |
| 226 | + | <span className="w-16 text-right">{formatBytes(pkg.size)}</span> |
| 227 | + | <span className="inline-flex w-14 items-center justify-end gap-1" title={`${pkg.downloads.toLocaleString("en-US")} downloads`}> |
| 228 | + | <Download size={12} /> |
| 229 | + | {shortCount(pkg.downloads)} |
| 126 | 230 | | </span> |
| 127 | 231 | | </span> |
| 128 | 232 | | </Link> |
| ⋯ |
| 130 | 234 | | ); |
| 131 | 235 | | } |
| 132 | 236 | | |
| 133 | | − | /** With no packages yet: how to push the first image. */ |
| 134 | | − | function FirstPackage({ workspace, username }: { workspace: string; username: string }) { |
| 237 | + | /** With no packages yet: every registry, what it is for, and the first step. */ |
| 238 | + | function ChooseRegistry({ workspace }: { workspace: string }) { |
| 135 | 239 | | return ( |
| 136 | | − | <section className="rounded-xl border border-line bg-surface p-6"> |
| 137 | | − | <h2 className="font-medium">Push the workspace's first image</h2> |
| 138 | | − | <p className="mt-1 text-sm text-muted"> |
| 139 | | − | Log in with a token that may write packages, tag an image under the workspace, and push it. An image named after one of the |
| 140 | | − | workspace's repositories is linked to it and has its access. |
| 141 | | − | </p> |
| 142 | | − | <div className="mt-4 space-y-2"> |
| 143 | | − | <CopyLine prompt text={`docker login g1t.sh -u ${username}`} /> |
| 144 | | − | <CopyLine prompt text={`docker tag web g1t.sh/${workspace}/web:latest`} /> |
| 145 | | − | <CopyLine prompt text={`docker push g1t.sh/${workspace}/web:latest`} /> |
| 240 | + | <div className="space-y-6"> |
| 241 | + | <div className="max-w-2xl"> |
| 242 | + | <h2 className="text-lg font-semibold tracking-tight">Choose a registry</h2> |
| 243 | + | <p className="mt-1 text-sm text-muted"> |
| 244 | + | Packages live beside the code that makes them, with the same members, roles and tokens. Workflows publish with{" "} |
| 245 | + | <code className="font-mono text-xs">G1T_TOKEN</code>. |
| 246 | + | </p> |
| 146 | 247 | | </div> |
| 147 | | − | <p className="mt-4 text-xs text-faint"> |
| 148 | | − | In a workflow, log in with <code className="font-mono">G1T_TOKEN</code>. See{" "} |
| 149 | | − | <a href="https://docs.g1t.sh/guides/containers/" className="underline hover:text-fg"> |
| 150 | | − | Containers |
| 151 | | − | </a> |
| 152 | | − | . |
| 153 | | − | </p> |
| 154 | | − | </section> |
| 248 | + | <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 249 | + | {REGISTRIES.map((registry) => ( |
| 250 | + | <li key={registry.ecosystem} className="flex flex-col rounded-xl border border-line bg-surface p-4"> |
| 251 | + | <div className="flex items-center gap-2.5"> |
| 252 | + | <PackageIcon ecosystem={registry.ecosystem} /> |
| 253 | + | <span className="font-medium">{ECOSYSTEM_LABEL[registry.ecosystem]}</span> |
| 254 | + | {!registry.ready && <Badge>Soon</Badge>} |
| 255 | + | </div> |
| 256 | + | <p className="mt-2.5 grow text-sm text-muted">{registry.blurb}</p> |
| 257 | + | {registry.ready && ( |
| 258 | + | <div className="mt-3"> |
| 259 | + | <CopyLine prompt text={registry.start.replaceAll("<workspace>", workspace)} /> |
| 260 | + | </div> |
| 261 | + | )} |
| 262 | + | <a |
| 263 | + | href={`${DOCS}${registry.guide}`} |
| 264 | + | className="mt-3 inline-flex items-center gap-1 self-start text-sm text-fg-soft hover:text-fg" |
| 265 | + | > |
| 266 | + | {registry.ready ? "Set it up" : "What's planned"} <ArrowUpRight size={13} /> |
| 267 | + | </a> |
| 268 | + | </li> |
| 269 | + | ))} |
| 270 | + | </ul> |
| 271 | + | </div> |
| 155 | 272 | | ); |
| 156 | 273 | | } |