Skip to content
539 linesCodeBlameRaw
1import { ArrowUpRight, Box, ChevronLeft, ChevronRight, LayoutGrid, List, Lock, Rocket, Search } from "lucide-react";
2import { type KeyboardEvent, useEffect, useRef, useState } from "react";
3import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router";
4
5import type { Route } from "./+types/projects";
6import { host } from "../../components/deploy";
7import { PinButton } from "../../components/pin-button";
8import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui";
9import { Checkbox } from "../../components/ui/checkbox";
10import { SelectField } from "../../components/ui/select";
11import { page as pageMeta } from "../../lib/meta";
12import {
13 LANGUAGE_OF,
14 type Listed,
15 type ProjectQuery,
16 SORTS,
17 facetsOf,
18 filterProjects,
19 isFiltered,
20 lastUpdated,
21 nextRow,
22 pageOf,
23 projectQueryString,
24 readProjectQuery,
25} from "../../lib/project-list";
26import { kindLabel, primaryLink } from "../../lib/project-kind";
27import { deployments, projects } from "../../lib/services.server";
28import { getViewer, roleIn } from "../../lib/session.server";
29import { workspaceProjects } from "../../lib/workspace-projects.server";
30
31export function meta({ params, ...args }: Route.MetaArgs) {
32 return pageMeta(args, { title: `Projects · ${params.owner} · g1t` });
33}
34
35/**
36 * Every project the viewer can see, filtered, sorted and paged here: one
37 * listing (shared with the sidebar's), with Deployments and the viewer's
38 * pins asked for at the same time.
39 */
40export async function loader({ params, context, request }: Route.LoaderArgs) {
41 const viewer = getViewer(context);
42 const slug = params.owner.toLowerCase();
43 const role = roleIn(viewer, slug);
44 const query = readProjectQuery(new URL(request.url).searchParams);
45 const [listed, deploys, shortcuts] = await Promise.all([
46 workspaceProjects(slug, viewer),
47 role ? deployments.overview(slug, viewer).catch(() => null) : null,
48 role ? projects.shortcuts(slug, viewer).catch(() => null) : null,
49 ]);
50 const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry]));
51 const full = new Map((listed.ok ? listed.value : []).map((project) => [project.id, project]));
52 const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({
53 id: project.id,
54 slug: project.slug,
55 name: project.name,
56 description: project.description,
57 private: project.private,
58 archived: project.archived,
59 kind: project.kind,
60 ecosystem: project.ecosystem,
61 updatedAt: project.updatedAt,
62 pushedAt: project.pushedAt ?? null,
63 activity: project.activity ?? 0,
64 deploying: live.get(project.slug)?.enabled ?? false,
65 }));
66 const matched = filterProjects(all, query);
67 const shown = pageOf(matched, query.page);
68 return {
69 slug,
70 role,
71 query: { ...query, page: shown.page },
72 total: all.length,
73 matched: matched.length,
74 page: { page: shown.page, pages: shown.pages, from: shown.from, to: shown.to },
75 // Production where g1t serves it or elsewhere, else its homepage or docs.
76 items: shown.items.map((project) => {
77 const one = full.get(project.id)!;
78 return { ...project, runs: one.runs, production: primaryLink(one, live.get(project.slug)?.production?.url) };
79 }),
80 facets: facetsOf(all),
81 pinned: (shortcuts?.pinned ?? []).map((project) => project.id),
82 failed: !listed.ok,
83 };
84}
85
86type Item = Route.ComponentProps["loaderData"]["items"][number];
87
88const CONTROL =
89 "h-9 rounded-md border border-line bg-surface px-2.5 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
90/** A filter's select, on the same surface as the other controls. */
91const FILTER = "w-auto bg-surface";
92
93/** Its language, its kind when it is not an app, and its state, as small words. */
94function Facts({ project }: { project: Item }) {
95 const language = project.ecosystem ? LANGUAGE_OF[project.ecosystem] : null;
96 return (
97 <>
98 {language && <span>{language}</span>}
99 {project.kind !== "app" && <span>{kindLabel(project)}</span>}
100 {project.kind === "app" && project.runs === "elsewhere" && <span>Deployed elsewhere</span>}
101 {project.deploying && (
102 <span className="inline-flex items-center gap-1 text-success">
103 <Rocket size={11} />
104 Deploys
105 </span>
106 )}
107 <span className="text-faint">
108 Updated <TimeAgo at={lastUpdated(project)} />
109 </span>
110 </>
111 );
112}
113
114function ProjectRow({ project, slug, pinned, member }: { project: Item; slug: string; pinned: boolean; member: boolean }) {
115 const to = `/${slug}/${project.slug}`;
116 return (
117 <li className="group relative flex items-center gap-3 px-4 py-3 transition-colors focus-within:bg-raised/60 hover:bg-raised/60">
118 <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
119 {project.private ? <Lock size={14} /> : <Box size={14} />}
120 </span>
121 <div className="min-w-0 grow">
122 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
123 <Link
124 to={to}
125 prefetch="intent"
126 data-row
127 className="truncate font-medium outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
128 >
129 {project.name}
130 </Link>
131 {project.name.toLowerCase() !== project.slug && <span className="truncate font-mono text-xs text-faint">{project.slug}</span>}
132 {project.private && <Pill>private</Pill>}
133 {project.archived && <Pill>archived</Pill>}
134 </div>
135 {project.description && <p className="mt-0.5 truncate text-sm text-muted">{project.description}</p>}
136 <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted">
137 <Facts project={project} />
138 </p>
139 </div>
140 {project.production && (
141 <a
142 href={project.production}
143 className="relative z-10 hidden shrink-0 items-center gap-1 font-mono text-xs text-muted hover:text-accent md:flex"
144 >
145 {host(project.production)}
146 <ArrowUpRight size={11} />
147 </a>
148 )}
149 {member && <PinButton workspace={slug} slug={project.slug} name={project.name} pinned={pinned} compact />}
150 </li>
151 );
152}
153
154function ProjectTile({ project, slug, pinned, member }: { project: Item; slug: string; pinned: boolean; member: boolean }) {
155 return (
156 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors focus-within:border-line-strong hover:border-line-strong">
157 <div className="flex items-start gap-3">
158 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
159 {project.private ? <Lock size={15} /> : <Box size={15} />}
160 </span>
161 <div className="min-w-0 grow">
162 <Link
163 to={`/${slug}/${project.slug}`}
164 prefetch="intent"
165 data-row
166 className="block truncate font-medium outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
167 >
168 {project.name}
169 </Link>
170 {project.production ? (
171 <a href={project.production} className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent">
172 {host(project.production)}
173 <ArrowUpRight size={11} className="shrink-0" />
174 </a>
175 ) : (
176 <p className="mt-0.5 truncate font-mono text-xs text-faint">{project.slug}</p>
177 )}
178 </div>
179 {member && <PinButton workspace={slug} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1 -mr-2" />}
180 </div>
181 <p className="mt-3 line-clamp-2 min-h-10 text-sm text-muted">{project.description ?? ""}</p>
182 <p className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-line pt-3 text-xs text-muted">
183 {project.private && <span>Private</span>}
184 {project.archived && <span>Archived</span>}
185 <Facts project={project} />
186 </p>
187 </li>
188 );
189}
190
191/** The pages around the current one: the first, the last, and two either side. */
192function pageNumbers(page: number, pages: number): (number | null)[] {
193 const wanted = new Set([1, pages, page - 2, page - 1, page, page + 1, page + 2].filter((n) => n >= 1 && n <= pages));
194 const sorted = [...wanted].sort((a, b) => a - b);
195 return sorted.flatMap((n, i) => (i > 0 && n - sorted[i - 1]! > 1 ? [null, n] : [n]));
196}
197
198function Pages({ query, page, pages }: { query: ProjectQuery; page: number; pages: number }) {
199 if (pages <= 1) return null;
200 const link = "flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm transition-colors";
201 return (
202 <nav aria-label="Pages" className="mt-6 flex flex-wrap items-center justify-center gap-1">
203 {page > 1 ? (
204 <Link to={projectQueryString(query, { page: page - 1 })} className={`${link} gap-1 text-muted hover:bg-raised hover:text-fg`}>
205 <ChevronLeft size={14} />
206 Previous
207 </Link>
208 ) : null}
209 {pageNumbers(page, pages).map((n, i) =>
210 n == null ? (
211 <span key={`gap${i}`} className="px-1 text-faint">
212 …
213 </span>
214 ) : (
215 <Link
216 key={n}
217 to={projectQueryString(query, { page: n })}
218 aria-current={n === page ? "page" : undefined}
219 className={`${link} tabular-nums ${n === page ? "bg-raised font-medium text-fg ring-1 ring-line" : "text-muted hover:bg-raised hover:text-fg"}`}
220 >
221 {n}
222 </Link>
223 ),
224 )}
225 {page < pages ? (
226 <Link to={projectQueryString(query, { page: page + 1 })} className={`${link} gap-1 text-muted hover:bg-raised hover:text-fg`}>
227 Next
228 <ChevronRight size={14} />
229 </Link>
230 ) : null}
231 </nav>
232 );
233}
234
235export default function WorkspaceProjects({ loaderData }: Route.ComponentProps) {
236 const { slug, role, query, total, matched, page, items, facets, pinned, failed } = loaderData;
237 const submit = useSubmit();
238 const navigation = useNavigation();
239 const { pathname } = useLocation();
240 const form = useRef<HTMLFormElement>(null);
241 const searchBox = useRef<HTMLInputElement>(null);
242 const typing = useRef<ReturnType<typeof setTimeout> | null>(null);
243 const deploysBox = useRef<HTMLButtonElement>(null);
244 const [deploys, setDeploys] = useState(query.deployments);
245 const member = role != null;
246 const pins = new Set(pinned);
247 // A filter or page of this list on its way: the list says it is busy.
248 const busy = navigation.state === "loading" && navigation.location?.pathname === pathname;
249
250 const apply = (delay = 0) => {
251 if (typing.current) clearTimeout(typing.current);
252 typing.current = setTimeout(() => {
253 if (form.current) submit(form.current, { replace: true, preventScrollReset: true });
254 }, delay);
255 };
256 useEffect(() => () => void (typing.current && clearTimeout(typing.current)), []);
257 // Back and forward change the address, not the fields: they follow it,
258 // except the one being used.
259 useEffect(() => {
260 const fields = form.current?.elements;
261 if (!fields) return;
262 const values: Record<string, string> = {
263 q: query.q,
264 visibility: query.visibility,
265 kind: query.kind,
266 language: query.language ?? "",
267 archived: query.archived,
268 sort: query.sort,
269 };
270 for (const [name, value] of Object.entries(values)) {
271 const field = fields.namedItem(name) as HTMLInputElement | HTMLSelectElement | null;
272 if (field && field !== document.activeElement && "value" in field) field.value = value;
273 }
274 if (deploysBox.current !== document.activeElement) setDeploys(query.deployments);
275 }, [query]);
276
277 // `/` goes to the search, from anywhere on the page that is not a field.
278 useEffect(() => {
279 const onKey = (event: globalThis.KeyboardEvent) => {
280 const target = event.target as HTMLElement | null;
281 if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
282 if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
283 event.preventDefault();
284 searchBox.current?.focus();
285 };
286 window.addEventListener("keydown", onKey);
287 return () => window.removeEventListener("keydown", onKey);
288 }, []);
289
290 // The arrows (and j and k) move between projects; Enter opens one.
291 const list = useRef<HTMLDivElement>(null);
292 const onListKey = (event: KeyboardEvent<HTMLDivElement>) => {
293 const rows = [...(list.current?.querySelectorAll<HTMLAnchorElement>("a[data-row]") ?? [])];
294 const at = rows.indexOf(document.activeElement as HTMLAnchorElement);
295 const key = event.key === "ArrowRight" ? "ArrowDown" : event.key === "ArrowLeft" ? "ArrowUp" : event.key;
296 if (at < 0 && key !== "ArrowDown" && key !== "j") return;
297 const to = nextRow(key, at, rows.length);
298 if (to == null || event.metaKey || event.ctrlKey || event.altKey) return;
299 event.preventDefault();
300 rows[to]?.focus();
301 };
302 // From the search box, down goes to the first project.
303 const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
304 if (event.key === "ArrowDown") {
305 event.preventDefault();
306 list.current?.querySelector<HTMLAnchorElement>("a[data-row]")?.focus();
307 }
308 if (event.key === "Escape" && event.currentTarget.value) {
309 event.currentTarget.value = "";
310 apply();
311 }
312 };
313
314 const filtered = isFiltered(query);
315 const option = (label: string, count: number) => `${label} (${count})`;
316
317 return (
318 <div>
319 <Form ref={form} id="project-filters" method="get" role="search" aria-label="Find projects" className="flex flex-wrap items-center gap-2">
320 <div className="relative w-full sm:w-72">
321 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
322 <input
323 ref={searchBox}
324 type="search"
325 name="q"
326 defaultValue={query.q}
327 {...notACredential()}
328 placeholder="Find a project…"
329 aria-label="Find a project"
330 aria-keyshortcuts="/"
331 onChange={() => apply(250)}
332 onKeyDown={onSearchKey}
333 className={`${CONTROL} w-full pr-8 pl-8 placeholder:text-faint`}
334 />
335 <kbd className="pointer-events-none absolute top-1/2 right-2 hidden -translate-y-1/2 rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line sm:block">
336 /
337 </kbd>
338 </div>
339 <SelectField
340 key={`visibility-${query.visibility}`}
341 name="visibility"
342 aria-label="Visibility"
343 defaultValue={query.visibility}
344 afterChange={() => apply()}
345 className={FILTER}
346 options={[
347 { value: "all", label: "All visibility" },
348 { value: "public", label: option("Public", facets.visibility.public) },
349 { value: "private", label: option("Private", facets.visibility.private) },
350 ]}
351 />
352 <SelectField
353 key={`kind-${query.kind}`}
354 name="kind"
355 aria-label="Kind"
356 defaultValue={query.kind}
357 afterChange={() => apply()}
358 className={FILTER}
359 options={[
360 { value: "all", label: "All kinds" },
361 { value: "app", label: option("Apps", facets.kind.app) },
362 { value: "library", label: option("Libraries", facets.kind.library) },
363 ...(facets.kind.tool > 0 || query.kind === "tool" ? [{ value: "tool", label: option("Tools", facets.kind.tool) }] : []),
364 ...(facets.kind.docs > 0 || query.kind === "docs" ? [{ value: "docs", label: option("Docs", facets.kind.docs) }] : []),
365 ...(facets.kind.other > 0 || query.kind === "other" ? [{ value: "other", label: option("Other", facets.kind.other) }] : []),
366 ]}
367 />
368 {(facets.languages.length > 0 || query.language) && (
369 <SelectField
370 key={`language-${query.language ?? ""}`}
371 name="language"
372 aria-label="Language"
373 defaultValue={query.language ?? ""}
374 afterChange={() => apply()}
375 className={FILTER}
376 options={[
377 { value: "", label: "Any language" },
378 ...facets.languages.map((language) => ({ value: language.name, label: option(language.name, language.count) })),
379 ]}
380 />
381 )}
382 {(facets.archived > 0 || query.archived !== "hide") && (
383 <SelectField
384 key={`archived-${query.archived}`}
385 name="archived"
386 aria-label="Archived"
387 defaultValue={query.archived}
388 afterChange={() => apply()}
389 className={FILTER}
390 options={[
391 { value: "hide", label: "Without archived" },
392 { value: "include", label: "With archived" },
393 { value: "only", label: option("Only archived", facets.archived) },
394 ]}
395 />
396 )}
397 {member && (
398 <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}>
399 <Checkbox
400 ref={deploysBox}
401 name="deployments"
402 value="on"
403 checked={deploys}
404 onCheckedChange={(checked) => {
405 setDeploys(checked === true);
406 apply();
407 }}
408 />
409 Deploys
410 <span className="text-xs tabular-nums text-faint">{facets.deploying}</span>
411 </label>
412 )}
413 {query.view !== "list" && <input type="hidden" name="view" value={query.view} />}
414 <noscript>
415 <button type="submit" className={CONTROL}>
416 Apply
417 </button>
418 </noscript>
419 </Form>
420
421 <div className="mt-4 flex flex-wrap items-center gap-3 text-sm">
422 <p className="text-muted" aria-live="polite">
423 {filtered ? (
424 <>
425 <span className="font-medium text-fg tabular-nums">{matched}</span> of {total} {total === 1 ? "project" : "projects"}
426 {query.q && (
427 <>
428 {" "}
429 matching <span className="text-fg">“{query.q}”</span>
430 </>
431 )}
432 </>
433 ) : (
434 <>
435 <span className="font-medium text-fg tabular-nums">{total}</span> {total === 1 ? "project" : "projects"}
436 </>
437 )}
438 {page.pages > 1 && (
439 <span className="text-faint">
440 {" "}
441 · showing {page.from}–{page.to}
442 </span>
443 )}
444 </p>
445 {filtered && (
446 <Link to={projectQueryString({ ...query, q: "", visibility: "all", kind: "all", language: null, deployments: false, archived: "hide" })} className="text-xs text-muted hover:text-fg">
447 Clear filters
448 </Link>
449 )}
450 <div className="ml-auto flex items-center gap-2">
451 {/* Part of the filters' form, beside the view it orders. */}
452 <SelectField
453 key={`sort-${query.sort}`}
454 form="project-filters"
455 name="sort"
456 aria-label="Sort"
457 defaultValue={query.sort}
458 afterChange={() => apply()}
459 size="sm"
460 className={FILTER}
461 align="end"
462 options={SORTS.map((sort) => ({ value: sort.value, label: sort.label }))}
463 />
464 <nav aria-label="View" className="flex items-center gap-0.5 rounded-lg border border-line bg-bg p-0.5">
465 {(
466 [
467 ["list", "List", <List key="list" size={14} />],
468 ["grid", "Grid", <LayoutGrid key="grid" size={14} />],
469 ] as const
470 ).map(([view, label, icon]) => (
471 <Link
472 key={view}
473 to={projectQueryString(query, { view, page: query.page })}
474 replace
475 preventScrollReset
476 aria-label={`${label} view`}
477 aria-current={query.view === view ? "true" : undefined}
478 className={`flex size-7 items-center justify-center rounded-md transition-colors ${
479 query.view === view ? "bg-raised text-fg ring-1 ring-line" : "text-faint hover:text-fg"
480 }`}
481 >
482 {icon}
483 </Link>
484 ))}
485 </nav>
486 </div>
487 </div>
488
489 <div ref={list} onKeyDown={onListKey} aria-busy={busy || undefined} className={`mt-4 transition-opacity ${busy ? "opacity-60" : ""}`}>
490 {failed ? (
491 <EmptyState title="Projects cannot be listed right now">Try again in a moment.</EmptyState>
492 ) : total === 0 ? (
493 <EmptyState title="No projects yet">
494 {member ? (
495 <>
496 <Link to={`/new?workspace=${slug}`} className="text-accent hover:underline">
497 Create one
498 </Link>
499 , or push a repository and it becomes one.
500 </>
501 ) : (
502 "This workspace has no public projects."
503 )}
504 </EmptyState>
505 ) : items.length === 0 ? (
506 <EmptyState title="No projects match">
507 Try other words or filters, or{" "}
508 <Link to={projectQueryString({ ...query, q: "", visibility: "all", kind: "all", language: null, deployments: false, archived: "hide" })} className="text-accent hover:underline">
509 clear them
510 </Link>
511 .
512 </EmptyState>
513 ) : query.view === "grid" ? (
514 <ul aria-label="Projects" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
515 {items.map((project) => (
516 <ProjectTile key={project.id} project={project} slug={slug} pinned={pins.has(project.id)} member={member} />
517 ))}
518 </ul>
519 ) : (
520 <ul aria-label="Projects" className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
521 {items.map((project) => (
522 <ProjectRow key={project.id} project={project} slug={slug} pinned={pins.has(project.id)} member={member} />
523 ))}
524 </ul>
525 )}
526 </div>
527 <Pages query={query} page={page.page} pages={page.pages} />
528 {items.length > 0 && (
529 <p className="mt-4 hidden text-center text-xs text-faint sm:block">
530 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">/</kbd> to find,{" "}
531 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">↑</kbd>{" "}
532 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">↓</kbd> to move,{" "}
533 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">Enter</kbd> to open
534 </p>
535 )}
536 </div>
537 );
538}
539