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