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