Skip to content
383 linesCodeBlameRaw
1import { ArrowUpRight, Box, Check, ChevronDown, Download, Lock, RotateCcw, Search, Trash2 } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import { ECOSYSTEMS, type Ecosystem, PACKAGE_RESTORE_DAYS, type PackageSummary } from "@g1t/contracts";
5
6import type { Route } from "./+types/packages";
7import { PackageIcon } from "../../components/package-icon";
8import { CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
9import { Badge } from "../../components/ui/badge";
10import { Card } from "../../components/ui/card";
11import { Hint } from "../../components/ui/hint";
12import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
13import { cn } from "../../lib/cn";
14import { page } from "../../lib/meta";
15import {
16 ECOSYSTEM_LABEL,
17 type PackageSort,
18 REGISTRIES,
19 SORT_LABEL,
20 VISIBILITY_LABEL,
21 type VisibilityFilter,
22 arrange,
23 formatBytes,
24 shortCount,
25} from "../../lib/packages";
26import { packages } from "../../lib/services.server";
27import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
28
29const DOCS = "https://docs.g1t.sh";
30
31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `Packages · ${params.owner} · g1t` });
33}
34
35function pick<T extends string>(value: string | null, options: readonly T[], fallback: T): T {
36 return value && (options as readonly string[]).includes(value) ? (value as T) : fallback;
37}
38
39export async function loader({ params, context, request }: Route.LoaderArgs) {
40 const viewer = getViewer(context);
41 // Members only for now; a workspace's public packages are found through
42 // their repositories and search.
43 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
44 const url = new URL(request.url);
45 const type = url.searchParams.get("type");
46 const ecosystem = type && (ECOSYSTEMS as readonly string[]).includes(type) ? (type as Ecosystem) : null;
47 const query = url.searchParams.get("q")?.trim() || null;
48 const visibility = pick<VisibilityFilter>(url.searchParams.get("visibility"), ["all", "public", "private"], "all");
49 const sort = pick<PackageSort>(url.searchParams.get("sort"), ["updated", "downloads", "name"], "updated");
50 const view = url.searchParams.get("view") === "deleted" ? "deleted" : "active";
51 // Deleted packages are listed for those who administer them, and only
52 // then is there a Deleted packages view.
53 const [found, deletedFound] = await Promise.all([
54 packages.list(params.owner, viewer, { ecosystem, query }),
55 packages.deleted(params.owner, viewer),
56 ]);
57 const listed = unwrap(found);
58 const deleted = deletedFound.ok ? deletedFound.value : [];
59 // Whether the workspace has any at all decides between a filtered-out
60 // list and the first-package page.
61 const any = listed.length > 0 || deleted.length > 0 || ecosystem != null || query != null;
62 return {
63 list: arrange(listed, visibility, sort),
64 deleted,
65 view,
66 any,
67 ecosystem,
68 query,
69 visibility,
70 sort,
71 workspace: params.owner.toLowerCase(),
72 };
73}
74
75type Outcome = { error: string | null; message: string | null };
76
77/** Restoring a deleted package, from the Deleted packages view. */
78export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome | Response> {
79 assertSameOrigin(request);
80 const user = requireUser(context, request);
81 const form = await request.formData();
82 if (form.get("intent") !== "restore") return { error: "That is not something this page does.", message: null };
83 const type = String(form.get("ecosystem") ?? "");
84 const name = String(form.get("name") ?? "");
85 if (!(ECOSYSTEMS as readonly string[]).includes(type) || !name) return { error: "Name the package to restore.", message: null };
86 const restored = await packages.restorePackage(user, params.owner, type as Ecosystem, name, "web");
87 if (!restored.ok) return { error: restored.error.message, message: null };
88 return redirect(`/${params.owner}/-/packages/${type}/${name}`);
89}
90
91type Filters = { type: string | null; q: string | null; visibility: VisibilityFilter; sort: PackageSort };
92
93/** The page's address with one filter changed, defaults left out. */
94function hrefWith(workspace: string, current: Filters, change: Partial<Filters>): string {
95 const next = { ...current, ...change };
96 const params = new URLSearchParams();
97 if (next.type) params.set("type", next.type);
98 if (next.q) params.set("q", next.q);
99 if (next.visibility !== "all") params.set("visibility", next.visibility);
100 if (next.sort !== "updated") params.set("sort", next.sort);
101 return `/${workspace}/-/packages${params.size ? `?${params}` : ""}`;
102}
103
104/** The packages a workspace publishes: filtered, sorted, or, with none yet, how to start. */
105export default function Packages({ loaderData, actionData }: Route.ComponentProps) {
106 const { list, deleted, view, any, ecosystem, query, visibility, sort, workspace } = loaderData;
107 if (!any) return <ChooseRegistry workspace={workspace} />;
108 if (view === "deleted") return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />;
109 const filters: Filters = { type: ecosystem, q: query, visibility, sort };
110 const filtered = ecosystem != null || query != null || visibility !== "all";
111 return (
112 <div className="space-y-4">
113 <div className="flex flex-wrap items-center gap-2">
114 <FilterMenu
115 label="Type"
116 value={ecosystem ? ECOSYSTEM_LABEL[ecosystem] : "All"}
117 options={[null, ...ECOSYSTEMS].map((option) => ({
118 label: option ? ECOSYSTEM_LABEL[option] : "All",
119 to: hrefWith(workspace, filters, { type: option }),
120 current: ecosystem === option,
121 }))}
122 />
123 <Form method="get" className="relative min-w-48 grow">
124 <label className="sr-only" htmlFor="package-search">
125 Search packages
126 </label>
127 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
128 <input
129 id="package-search"
130 name="q"
131 defaultValue={query ?? ""}
132 placeholder="Search packages"
133 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"
134 />
135 {ecosystem && <input type="hidden" name="type" value={ecosystem} />}
136 {visibility !== "all" && <input type="hidden" name="visibility" value={visibility} />}
137 {sort !== "updated" && <input type="hidden" name="sort" value={sort} />}
138 </Form>
139 <FilterMenu
140 label="Visibility"
141 value={VISIBILITY_LABEL[visibility]}
142 options={(["all", "public", "private"] as const).map((option) => ({
143 label: VISIBILITY_LABEL[option],
144 to: hrefWith(workspace, filters, { visibility: option }),
145 current: visibility === option,
146 }))}
147 />
148 <FilterMenu
149 label="Sort"
150 value={SORT_LABEL[sort]}
151 options={(["updated", "downloads", "name"] as const).map((option) => ({
152 label: SORT_LABEL[option],
153 to: hrefWith(workspace, filters, { sort: option }),
154 current: sort === option,
155 }))}
156 />
157 </div>
158
159 <Card asChild className="overflow-hidden">
160 <section>
161 <header className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5 text-sm">
162 <span className="font-medium">
163 {list.length} {list.length === 1 ? "package" : "packages"}
164 {filtered && (
165 <Link to={`/${workspace}/-/packages`} className="ml-3 text-xs font-normal text-muted hover:text-fg">
166 Clear filters
167 </Link>
168 )}
169 </span>
170 <span className="flex items-center gap-4">
171 {deleted.length > 0 && (
172 <Link to={`/${workspace}/-/packages?view=deleted`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
173 <Trash2 size={12} />
174 Deleted packages <span className="rounded-full bg-raised px-1.5 text-faint tabular-nums">{deleted.length}</span>
175 </Link>
176 )}
177 <a href={`${DOCS}/guides/packages/`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
178 How packages work <ArrowUpRight size={12} />
179 </a>
180 </span>
181 </header>
182 {list.length === 0 ? (
183 <div className="p-4">
184 <EmptyState title="No packages match">Try another type or visibility, or clear the search.</EmptyState>
185 </div>
186 ) : (
187 <ul className="divide-y divide-line">
188 {list.map((pkg) => (
189 <PackageRow key={pkg.id} pkg={pkg} />
190 ))}
191 </ul>
192 )}
193 </section>
194 </Card>
195 </div>
196 );
197}
198
199/** A filter as a button that opens its choices; each choice is a link, so the address says the view. */
200function FilterMenu({ label, value, options }: { label: string; value: string; options: { label: string; to: string; current: boolean }[] }) {
201 return (
202 <DropdownMenu>
203 <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">
204 <span className="text-muted">{label}:</span>
205 <span className="font-medium">{value}</span>
206 <ChevronDown size={13} className="text-faint" />
207 </DropdownMenuTrigger>
208 <DropdownMenuContent align="start" className="min-w-44">
209 {options.map((option) => (
210 <DropdownMenuItem key={option.label} asChild>
211 <Link to={option.to} preventScrollReset>
212 <Check size={14} className={cn(!option.current && "invisible")} />
213 {option.label}
214 </Link>
215 </DropdownMenuItem>
216 ))}
217 </DropdownMenuContent>
218 </DropdownMenu>
219 );
220}
221
222function PackageRow({ pkg }: { pkg: PackageSummary }) {
223 return (
224 <li>
225 <Link
226 to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`}
227 className="flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-raised/50"
228 >
229 <PackageIcon ecosystem={pkg.ecosystem} />
230 <span className="min-w-0 grow">
231 <span className="flex flex-wrap items-center gap-2">
232 <span className="truncate font-medium">{pkg.name}</span>
233 {pkg.visibility === "private" && (
234 <Badge>
235 <Lock size={10} />
236 Private
237 </Badge>
238 )}
239 </span>
240 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
241 <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
242 <span className="text-faint">·</span>
243 <span>
244 Updated <TimeAgo at={pkg.updated_at} />
245 </span>
246 {pkg.repo && (
247 <>
248 <span className="text-faint">·</span>
249 <span className="inline-flex items-center gap-1">
250 <Box size={11} />
251 {pkg.repo.name}
252 </span>
253 </>
254 )}
255 {pkg.description && (
256 <>
257 <span className="text-faint">·</span>
258 <span className="max-w-md truncate">{pkg.description}</span>
259 </>
260 )}
261 </span>
262 </span>
263 <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex">
264 {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>}
265 <span className="w-16 text-right">{formatBytes(pkg.size)}</span>
266 <Hint label={`${pkg.downloads.toLocaleString("en-US")} downloads`}>
267 <span className="inline-flex w-14 items-center justify-end gap-1">
268 <Download size={12} />
269 {shortCount(pkg.downloads)}
270 <span className="sr-only"> downloads</span>
271 </span>
272 </Hint>
273 </span>
274 </Link>
275 </li>
276 );
277}
278
279/** Deleted packages that can still be restored, for those who administer them. */
280function DeletedPackages({ workspace, deleted, outcome }: { workspace: string; deleted: PackageSummary[]; outcome: Outcome | undefined }) {
281 return (
282 <div className="space-y-4">
283 <Link to={`/${workspace}/-/packages`} className="text-sm text-muted hover:text-fg">
284 Packages
285 </Link>
286 <div className="max-w-2xl">
287 <h2 className="text-lg font-semibold tracking-tight">Deleted packages</h2>
288 <p className="mt-1 text-sm text-muted">
289 Packages deleted in the last {PACKAGE_RESTORE_DAYS} days, with every version they had. Restore one to bring it back as it was;
290 until it is purged, nobody else can publish a package of its name.
291 </p>
292 </div>
293 {outcome?.error && <ErrorText>{outcome.error}</ErrorText>}
294 {deleted.length === 0 ? (
295 <EmptyState title="No deleted packages">Packages you delete stay here, restorable, for {PACKAGE_RESTORE_DAYS} days.</EmptyState>
296 ) : (
297 <Card asChild divided className="overflow-hidden">
298 <ul>
299 {deleted.map((pkg) => (
300 <li key={pkg.id} className="flex flex-wrap items-center gap-x-3.5 gap-y-2 px-4 py-3.5">
301 <PackageIcon ecosystem={pkg.ecosystem} />
302 <span className="min-w-0 grow basis-48">
303 <span className="block truncate font-medium">{pkg.name}</span>
304 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
305 <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
306 <span className="text-faint">·</span>
307 <span>
308 {pkg.versions} {pkg.versions === 1 ? "version" : "versions"}
309 </span>
310 {pkg.deleted_at && (
311 <>
312 <span className="text-faint">·</span>
313 <span>
314 Deleted {pkg.deleted_by ? `by ${pkg.deleted_by} ` : ""}
315 <TimeAgo at={pkg.deleted_at} />
316 </span>
317 </>
318 )}
319 {pkg.purge_at && (
320 <>
321 <span className="text-faint">·</span>
322 <span>Purged {new Date(pkg.purge_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
323 </>
324 )}
325 </span>
326 </span>
327 <Form method="post">
328 <input type="hidden" name="intent" value="restore" />
329 <input type="hidden" name="ecosystem" value={pkg.ecosystem} />
330 <input type="hidden" name="name" value={pkg.name} />
331 <SubmitButton variant="outline" match={{ intent: "restore", name: pkg.name }} pending="Restoring…">
332 <RotateCcw size={14} />
333 Restore
334 </SubmitButton>
335 </Form>
336 </li>
337 ))}
338 </ul>
339 </Card>
340 )}
341 </div>
342 );
343}
344
345/** With no packages yet: every registry, what it is for, and the first step. */
346function ChooseRegistry({ workspace }: { workspace: string }) {
347 return (
348 <div className="space-y-6">
349 <div className="max-w-2xl">
350 <h2 className="text-lg font-semibold tracking-tight">Choose a registry</h2>
351 <p className="mt-1 text-sm text-muted">
352 Packages live beside the code that makes them, with the same members, roles and tokens. Workflows publish with{" "}
353 <code className="font-mono text-xs">G1T_TOKEN</code>.
354 </p>
355 </div>
356 <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
357 {REGISTRIES.map((registry) => (
358 <Card asChild key={registry.ecosystem} className="flex flex-col p-4">
359 <li>
360 <div className="flex items-center gap-2.5">
361 <PackageIcon ecosystem={registry.ecosystem} />
362 <span className="font-medium">{ECOSYSTEM_LABEL[registry.ecosystem]}</span>
363 {!registry.ready && <Badge>Soon</Badge>}
364 </div>
365 <p className="mt-2.5 grow text-sm text-muted">{registry.blurb}</p>
366 {registry.ready && (
367 <div className="mt-3">
368 <CopyLine prompt text={registry.start.replaceAll("<workspace>", workspace)} />
369 </div>
370 )}
371 <a
372 href={`${DOCS}${registry.guide}`}
373 className="mt-3 inline-flex items-center gap-1 self-start text-sm text-fg-soft hover:text-fg"
374 >
375 {registry.ready ? "Set it up" : "What's planned"} <ArrowUpRight size={13} />
376 </a>
377 </li>
378 </Card>
379 ))}
380 </ul>
381 </div>
382 );
383}