Skip to content

g1t/apps/web/app/routes/workspace/packages.tsx

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