Skip to content

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

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